adasd
This commit is contained in:
26
modules/kea/migrations/001_1.0.0_baseline.php
Normal file
26
modules/kea/migrations/001_1.0.0_baseline.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Database;
|
||||||
|
use App\ModuleMigrationContext;
|
||||||
|
use App\Repository\KeaHostMetadataRepository;
|
||||||
|
|
||||||
|
return new class {
|
||||||
|
public function up(ModuleMigrationContext $context): void
|
||||||
|
{
|
||||||
|
$settings = $context->settings();
|
||||||
|
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
|
||||||
|
? $context->module['metadata_db_defaults']
|
||||||
|
: [];
|
||||||
|
$config = is_array($settings['metadata_db'] ?? null)
|
||||||
|
? array_replace($fallback, $settings['metadata_db'])
|
||||||
|
: $fallback;
|
||||||
|
|
||||||
|
if (empty($config['driver']) || empty($config['dbname'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
|
||||||
|
$repo->ensureSchema();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "KEA DHCP",
|
"title": "KEA DHCP",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"schema_version": 1,
|
||||||
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
|
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
|
||||||
"menu": [
|
"menu": [
|
||||||
{ "label": "Hosts", "href": "/module/kea" },
|
{ "label": "Hosts", "href": "/module/kea" },
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ $error = null;
|
|||||||
$notice = null;
|
$notice = null;
|
||||||
$host = null;
|
$host = null;
|
||||||
$metadataRepo = null;
|
$metadataRepo = null;
|
||||||
|
$groups = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = modules()->modulePdo('kea', $fallback);
|
$pdo = modules()->modulePdo('kea', $fallback);
|
||||||
@@ -27,6 +28,7 @@ try {
|
|||||||
|
|
||||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||||
$metadataRepo->ensureSchema();
|
$metadataRepo->ensureSchema();
|
||||||
|
$groups = $metadataRepo->listGroups();
|
||||||
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
||||||
$host = $repo->findDisplayByKey($source, $id);
|
$host = $repo->findDisplayByKey($source, $id);
|
||||||
if (!$host) {
|
if (!$host) {
|
||||||
@@ -40,16 +42,26 @@ try {
|
|||||||
'owner' => $_POST['owner'] ?? '',
|
'owner' => $_POST['owner'] ?? '',
|
||||||
'location' => $_POST['location'] ?? '',
|
'location' => $_POST['location'] ?? '',
|
||||||
'device_type' => $_POST['device_type'] ?? '',
|
'device_type' => $_POST['device_type'] ?? '',
|
||||||
|
'group_name' => $_POST['group_name'] ?? '',
|
||||||
|
'desired_ip' => $_POST['desired_ip'] ?? '',
|
||||||
'notes' => $_POST['notes'] ?? '',
|
'notes' => $_POST['notes'] ?? '',
|
||||||
'tags' => [],
|
'tags' => [],
|
||||||
];
|
];
|
||||||
$metadataRepo->saveForHost(
|
$desiredIp = trim((string)$metadata['desired_ip']);
|
||||||
$id,
|
if ($desiredIp !== '') {
|
||||||
(string)($host['dhcp_identifier'] ?? ''),
|
$newHostId = $repo->reserveDisplayEntry($host, $desiredIp, $metadata);
|
||||||
(string)($host['ipv4_address'] ?? ''),
|
$source = 'reservation';
|
||||||
$metadata
|
$id = $newHostId;
|
||||||
);
|
$notice = 'Zusatzdaten gespeichert und KEA-Reservierung gesetzt.';
|
||||||
$notice = 'Zusatzdaten gespeichert.';
|
} else {
|
||||||
|
$metadataRepo->saveForHost(
|
||||||
|
$id,
|
||||||
|
(string)($host['dhcp_identifier'] ?? ''),
|
||||||
|
(string)($host['ipv4_address'] ?? ''),
|
||||||
|
$metadata
|
||||||
|
);
|
||||||
|
$notice = 'Zusatzdaten gespeichert.';
|
||||||
|
}
|
||||||
$host = $repo->findDisplayByKey($source, $id) ?: $host;
|
$host = $repo->findDisplayByKey($source, $id) ?: $host;
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -114,6 +126,20 @@ $metadata = is_array($host['metadata'] ?? null) ? $host['metadata'] : [];
|
|||||||
<span>Gerätetyp</span>
|
<span>Gerätetyp</span>
|
||||||
<input type="text" name="device_type" value="<?= e((string)($metadata['device_type'] ?? '')) ?>">
|
<input type="text" name="device_type" value="<?= e((string)($metadata['device_type'] ?? '')) ?>">
|
||||||
</label>
|
</label>
|
||||||
|
<label class="setup-field">
|
||||||
|
<span>Gruppe</span>
|
||||||
|
<input type="text" name="group_name" list="kea-groups" value="<?= e((string)($metadata['group_name'] ?? '')) ?>">
|
||||||
|
<datalist id="kea-groups">
|
||||||
|
<?php foreach ($groups as $group): ?>
|
||||||
|
<option value="<?= e($group) ?>"></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
<label class="setup-field">
|
||||||
|
<span>Feste IP</span>
|
||||||
|
<input type="text" name="desired_ip" value="<?= e((string)($metadata['desired_ip'] ?? '')) ?>" placeholder="<?= e((string)($host['ipv4_address'] ?? '')) ?>">
|
||||||
|
<small class="muted">Wenn gesetzt, wird der Eintrag als KEA-Reservierung gespeichert.</small>
|
||||||
|
</label>
|
||||||
<label class="setup-field kea-edit-form__wide">
|
<label class="setup-field kea-edit-form__wide">
|
||||||
<span>Notizen</span>
|
<span>Notizen</span>
|
||||||
<textarea name="notes" rows="4"><?= e((string)($metadata['notes'] ?? '')) ?></textarea>
|
<textarea name="notes" rows="4"><?= e((string)($metadata['notes'] ?? '')) ?></textarea>
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ $metadataRepo = null;
|
|||||||
$hosts = [];
|
$hosts = [];
|
||||||
$error = null;
|
$error = null;
|
||||||
$warnings = [];
|
$warnings = [];
|
||||||
|
$stats = [
|
||||||
|
'total' => 0,
|
||||||
|
'reservations' => 0,
|
||||||
|
'leases' => 0,
|
||||||
|
'groups' => [],
|
||||||
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = modules()->modulePdo('kea', $fallback);
|
$pdo = modules()->modulePdo('kea', $fallback);
|
||||||
@@ -30,8 +36,20 @@ try {
|
|||||||
|
|
||||||
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
||||||
$hosts = $repo->findAll(50);
|
$hosts = $repo->findAll(50);
|
||||||
|
$stats['total'] = count($hosts);
|
||||||
|
foreach ($hosts as $host) {
|
||||||
|
if (($host['source'] ?? '') === 'lease') {
|
||||||
|
$stats['leases']++;
|
||||||
|
} else {
|
||||||
|
$stats['reservations']++;
|
||||||
|
}
|
||||||
|
$group = trim((string)($host['metadata']['group_name'] ?? ''));
|
||||||
|
if ($group !== '') {
|
||||||
|
$stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$error = "Datenbankfehler: " . $e->getMessage();
|
$error = "Datenbankfehler: " . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
module_tpl('kea', 'dashboard', compact('hosts', 'error', 'warnings'));
|
module_tpl('kea', 'dashboard', compact('hosts', 'error', 'warnings', 'stats'));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* @var array $hosts Die Liste der KEA-Hosts.
|
* @var array $hosts Die Liste der KEA-Hosts.
|
||||||
* @var string|null $error Eine Fehlermeldung, falls vorhanden.
|
* @var string|null $error Eine Fehlermeldung, falls vorhanden.
|
||||||
* @var array $warnings Hinweise, falls Zusatzdaten nicht geladen werden konnten.
|
* @var array $warnings Hinweise, falls Zusatzdaten nicht geladen werden konnten.
|
||||||
|
* @var array $stats Kennzahlen fuer die Uebersicht.
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<section class="kea-page">
|
<section class="kea-page">
|
||||||
@@ -27,6 +28,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Einträge</span>
|
||||||
|
<span class="stat-value"><?= e((string)($stats['total'] ?? 0)) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Reservierungen</span>
|
||||||
|
<span class="stat-value"><?= e((string)($stats['reservations'] ?? 0)) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Leases</span>
|
||||||
|
<span class="stat-value"><?= e((string)($stats['leases'] ?? 0)) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Gruppen</span>
|
||||||
|
<span class="stat-value"><?= e((string)count($stats['groups'] ?? [])) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="kea-panel">
|
<div class="kea-panel">
|
||||||
<div class="kea-panel__head">
|
<div class="kea-panel__head">
|
||||||
<div>
|
<div>
|
||||||
@@ -45,13 +65,14 @@
|
|||||||
<th>MAC Adresse</th>
|
<th>MAC Adresse</th>
|
||||||
<th>Echter Name</th>
|
<th>Echter Name</th>
|
||||||
<th>Standort</th>
|
<th>Standort</th>
|
||||||
|
<th>Gruppe</th>
|
||||||
<th>Aktion</th>
|
<th>Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($hosts)): ?>
|
<?php if (empty($hosts)): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="kea-empty">
|
<td colspan="8" class="kea-empty">
|
||||||
Keine Reservierungen oder aktiven Leases gefunden.
|
Keine Reservierungen oder aktiven Leases gefunden.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -62,7 +83,7 @@
|
|||||||
<span class="pill"><?= ($host['source'] ?? '') === 'lease' ? 'Lease' : 'Reservierung' ?></span>
|
<span class="pill"><?= ($host['source'] ?? '') === 'lease' ? 'Lease' : 'Reservierung' ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?= e($host['hostname'] ?: 'Unbekannt') ?>
|
<?= e((string)($host['metadata']['device_name'] ?? $host['metadata']['real_name'] ?? $host['display_name'] ?? $host['hostname'] ?? 'Unbekannt')) ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="mono">
|
<td class="mono">
|
||||||
<?= e($host['ipv4_address']) ?>
|
<?= e($host['ipv4_address']) ?>
|
||||||
@@ -76,6 +97,9 @@
|
|||||||
<td>
|
<td>
|
||||||
<?= e((string)($host['metadata']['location'] ?? '-')) ?>
|
<?= e((string)($host['metadata']['location'] ?? '-')) ?>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<?= e((string)($host['metadata']['group_name'] ?? '-')) ?>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="nav-link" href="/module/kea/edit?source=<?= e((string)($host['source'] ?? 'reservation')) ?>&id=<?= e((string)($host['host_id'] ?? '0')) ?>">
|
<a class="nav-link" href="/module/kea/edit?source=<?= e((string)($host['source'] ?? 'reservation')) ?>&id=<?= e((string)($host['host_id'] ?? '0')) ?>">
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
modules()->setEnabled($name, $enabled);
|
modules()->setEnabled($name, $enabled);
|
||||||
$notice = $enabled ? 'Modul aktiviert.' : 'Modul deaktiviert.';
|
$notice = $enabled ? 'Modul aktiviert.' : 'Modul deaktiviert.';
|
||||||
$modules = modules()->all();
|
$modules = modules()->all();
|
||||||
|
} elseif ($name !== '' && $action === 'migrate') {
|
||||||
|
$applied = modules()->applyPendingMigrations($name);
|
||||||
|
$notice = count($applied) . ' Migration(en) angewendet.';
|
||||||
|
$modules = modules()->all();
|
||||||
} else {
|
} else {
|
||||||
$error = 'Ungültige Aktion.';
|
$error = 'Ungültige Aktion.';
|
||||||
}
|
}
|
||||||
@@ -49,6 +53,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$accessLabel = $hasSpecificAccess
|
$accessLabel = $hasSpecificAccess
|
||||||
? 'Spezielle Zugriffsrechte'
|
? 'Spezielle Zugriffsrechte'
|
||||||
: ($authRequired ? 'Login erforderlich' : 'Kein Modulschutz');
|
: ($authRequired ? 'Login erforderlich' : 'Kein Modulschutz');
|
||||||
|
$migrationStatus = modules()->migrationStatus($module['name']);
|
||||||
|
$pendingMigrations = $migrationStatus['pending'] ?? [];
|
||||||
|
$changedMigrations = $migrationStatus['changed'] ?? [];
|
||||||
?>
|
?>
|
||||||
<div class="card" style="background:var(--panel-2);">
|
<div class="card" style="background:var(--panel-2);">
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||||
@@ -61,8 +68,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<span class="pill" title="<?= e($accessLabel) ?>" style="<?= $hasSpecificAccess ? 'border-color:#f59e0b; color:#fbbf24;' : '' ?>">
|
<span class="pill" title="<?= e($accessLabel) ?>" style="<?= $hasSpecificAccess ? 'border-color:#f59e0b; color:#fbbf24;' : '' ?>">
|
||||||
<?= e($accessLabel) ?>
|
<?= e($accessLabel) ?>
|
||||||
</span>
|
</span>
|
||||||
|
<?php if ($pendingMigrations !== []): ?>
|
||||||
|
<span class="pill" style="border-color:var(--accent-orange); color:var(--accent-orange);">
|
||||||
|
<?= e((string)count($pendingMigrations)) ?> Migration(en)
|
||||||
|
</span>
|
||||||
|
<?php elseif (($migrationStatus['available'] ?? 0) > 0): ?>
|
||||||
|
<span class="pill" style="border-color:var(--accent-green); color:var(--accent-green);">Schema aktuell</span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if ($changedMigrations !== []): ?>
|
||||||
|
<div class="muted" style="margin-top:.65rem; font-size:.85rem; color:var(--accent-orange);">
|
||||||
|
Achtung: <?= e((string)count($changedMigrations)) ?> bereits angewendete Migration(en) wurden veraendert.
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if ($hasSpecificAccess): ?>
|
<?php if ($hasSpecificAccess): ?>
|
||||||
<div class="muted" style="margin-top:.65rem; font-size:.85rem;">
|
<div class="muted" style="margin-top:.65rem; font-size:.85rem;">
|
||||||
<?php if ($authGroups !== []): ?>
|
<?php if ($authGroups !== []): ?>
|
||||||
@@ -80,6 +99,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<a class="nav-link" href="/module/<?= e($module['name']) ?>">Öffnen</a>
|
<a class="nav-link" href="/module/<?= e($module['name']) ?>">Öffnen</a>
|
||||||
<a class="nav-link" href="/modules/setup/<?= e($module['name']) ?>">Setup</a>
|
<a class="nav-link" href="/modules/setup/<?= e($module['name']) ?>">Setup</a>
|
||||||
<a class="nav-link" href="/modules/access/<?= e($module['name']) ?>">Zugriff</a>
|
<a class="nav-link" href="/modules/access/<?= e($module['name']) ?>">Zugriff</a>
|
||||||
|
<?php if ($pendingMigrations !== []): ?>
|
||||||
|
<form method="post" style="margin:0;">
|
||||||
|
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
||||||
|
<button class="cta-button" name="action" value="migrate">Migrationen anwenden</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
<form method="post" style="margin:0;">
|
<form method="post" style="margin:0;">
|
||||||
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
||||||
<button class="cta-button" name="action" value="disable" style="background:var(--panel); color:var(--text);">Deaktivieren</button>
|
<button class="cta-button" name="action" value="disable" style="background:var(--panel); color:var(--text);">Deaktivieren</button>
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
modules()->setEnabled($name, $action === 'enable');
|
modules()->setEnabled($name, $action === 'enable');
|
||||||
$notice = $action === 'enable' ? 'Modul aktiviert.' : 'Modul deaktiviert.';
|
$notice = $action === 'enable' ? 'Modul aktiviert.' : 'Modul deaktiviert.';
|
||||||
$modules = modules()->all();
|
$modules = modules()->all();
|
||||||
|
} elseif ($name !== '' && $action === 'migrate') {
|
||||||
|
$applied = modules()->applyPendingMigrations($name);
|
||||||
|
$notice = count($applied) . ' Migration(en) angewendet.';
|
||||||
|
$modules = modules()->all();
|
||||||
} else {
|
} else {
|
||||||
$error = 'Ungültige Aktion.';
|
$error = 'Ungültige Aktion.';
|
||||||
}
|
}
|
||||||
@@ -48,11 +52,21 @@ foreach ($modules as $m) {
|
|||||||
<h3 style="margin-top:1.25rem;">Aktive Module</h3>
|
<h3 style="margin-top:1.25rem;">Aktive Module</h3>
|
||||||
<div style="margin-top:.5rem;" class="grid">
|
<div style="margin-top:.5rem;" class="grid">
|
||||||
<?php foreach ($active as $module): ?>
|
<?php foreach ($active as $module): ?>
|
||||||
|
<?php
|
||||||
|
$migrationStatus = modules()->migrationStatus($module['name']);
|
||||||
|
$pendingMigrations = $migrationStatus['pending'] ?? [];
|
||||||
|
?>
|
||||||
<div class="card" style="background:var(--panel-2);">
|
<div class="card" style="background:var(--panel-2);">
|
||||||
<strong><?= e($module['title']) ?></strong>
|
<strong><?= e($module['title']) ?></strong>
|
||||||
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
|
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
|
||||||
<div style="margin-top:.75rem; display:flex; gap:10px;">
|
<div style="margin-top:.75rem; display:flex; gap:10px;">
|
||||||
<a class="nav-link" href="/module/<?= e($module['name']) ?>">Öffnen</a>
|
<a class="nav-link" href="/module/<?= e($module['name']) ?>">Öffnen</a>
|
||||||
|
<?php if ($pendingMigrations !== []): ?>
|
||||||
|
<form method="post" style="margin:0;">
|
||||||
|
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
||||||
|
<button class="cta-button" name="action" value="migrate">Migrationen anwenden</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
<form method="post" style="margin:0;">
|
<form method="post" style="margin:0;">
|
||||||
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
||||||
<button class="cta-button" name="action" value="disable" style="background:var(--panel); color:var(--text);">Deaktivieren</button>
|
<button class="cta-button" name="action" value="disable" style="background:var(--panel); color:var(--text);">Deaktivieren</button>
|
||||||
@@ -65,11 +79,21 @@ foreach ($modules as $m) {
|
|||||||
<h3 style="margin-top:1.5rem;">Deaktivierte Module</h3>
|
<h3 style="margin-top:1.5rem;">Deaktivierte Module</h3>
|
||||||
<div style="margin-top:.5rem;" class="grid">
|
<div style="margin-top:.5rem;" class="grid">
|
||||||
<?php foreach ($inactive as $module): ?>
|
<?php foreach ($inactive as $module): ?>
|
||||||
|
<?php
|
||||||
|
$migrationStatus = modules()->migrationStatus($module['name']);
|
||||||
|
$pendingMigrations = $migrationStatus['pending'] ?? [];
|
||||||
|
?>
|
||||||
<div class="card" style="background:var(--panel-2);">
|
<div class="card" style="background:var(--panel-2);">
|
||||||
<strong><?= e($module['title']) ?></strong>
|
<strong><?= e($module['title']) ?></strong>
|
||||||
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
|
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
|
||||||
<div style="margin-top:.75rem; display:flex; gap:10px;">
|
<div style="margin-top:.75rem; display:flex; gap:10px;">
|
||||||
<a class="nav-link" href="/modules/setup/<?= e($module['name']) ?>">Setup</a>
|
<a class="nav-link" href="/modules/setup/<?= e($module['name']) ?>">Setup</a>
|
||||||
|
<?php if ($pendingMigrations !== []): ?>
|
||||||
|
<form method="post" style="margin:0;">
|
||||||
|
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
||||||
|
<button class="cta-button" name="action" value="migrate">Migrationen anwenden</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
<form method="post" style="margin:0;">
|
<form method="post" style="margin:0;">
|
||||||
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
|
||||||
<button class="cta-button" name="action" value="enable">Aktivieren</button>
|
<button class="cta-button" name="action" value="enable">Aktivieren</button>
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ final class BaseSchema
|
|||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_module_migrations (
|
||||||
|
module_name TEXT NOT NULL,
|
||||||
|
migration TEXT NOT NULL,
|
||||||
|
version TEXT,
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (module_name, migration)
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
|
||||||
$pdo->exec(
|
$pdo->exec(
|
||||||
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
|
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
@@ -117,6 +128,17 @@ final class BaseSchema
|
|||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_module_migrations (
|
||||||
|
module_name TEXT NOT NULL,
|
||||||
|
migration TEXT NOT NULL,
|
||||||
|
version TEXT,
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (module_name, migration)
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
|
||||||
$pdo->exec(
|
$pdo->exec(
|
||||||
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
|
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
@@ -194,6 +216,17 @@ final class BaseSchema
|
|||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_module_migrations (
|
||||||
|
module_name VARCHAR(190) NOT NULL,
|
||||||
|
migration VARCHAR(190) NOT NULL,
|
||||||
|
version VARCHAR(64),
|
||||||
|
checksum VARCHAR(64) NOT NULL,
|
||||||
|
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (module_name, migration)
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
|
||||||
$pdo->exec(
|
$pdo->exec(
|
||||||
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
|
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
|
||||||
name VARCHAR(190) PRIMARY KEY,
|
name VARCHAR(190) PRIMARY KEY,
|
||||||
|
|||||||
@@ -65,6 +65,24 @@ final class ModuleManager
|
|||||||
$this->modules[$name]['enabled'] = $enabled;
|
$this->modules[$name]['enabled'] = $enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function migrationStatus(string $name): array
|
||||||
|
{
|
||||||
|
if (!$this->basePdo || !isset($this->modules[$name])) {
|
||||||
|
return ['available' => 0, 'applied' => 0, 'pending' => [], 'changed' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new ModuleMigrationService($this->basePdo, $this))->status($this->modules[$name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPendingMigrations(string $name): array
|
||||||
|
{
|
||||||
|
if (!$this->basePdo || !isset($this->modules[$name])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new ModuleMigrationService($this->basePdo, $this))->applyPending($this->modules[$name]);
|
||||||
|
}
|
||||||
|
|
||||||
public function settings(string $name): array
|
public function settings(string $name): array
|
||||||
{
|
{
|
||||||
if (!$this->basePdo) {
|
if (!$this->basePdo) {
|
||||||
@@ -260,6 +278,7 @@ final class ModuleManager
|
|||||||
'sidebar' => $data['sidebar'] ?? [],
|
'sidebar' => $data['sidebar'] ?? [],
|
||||||
'db_defaults' => $data['db_defaults'] ?? [],
|
'db_defaults' => $data['db_defaults'] ?? [],
|
||||||
'metadata_db_defaults' => $data['metadata_db_defaults'] ?? [],
|
'metadata_db_defaults' => $data['metadata_db_defaults'] ?? [],
|
||||||
|
'schema_version' => (int)($data['schema_version'] ?? 0),
|
||||||
'path' => $dir,
|
'path' => $dir,
|
||||||
'entry' => '/module/' . rawurlencode($name),
|
'entry' => '/module/' . rawurlencode($name),
|
||||||
'auth' => $this->loadAuth($name, $manifestAuth),
|
'auth' => $this->loadAuth($name, $manifestAuth),
|
||||||
|
|||||||
18
src/App/ModuleMigrationContext.php
Normal file
18
src/App/ModuleMigrationContext.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class ModuleMigrationContext
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly \PDO $basePdo,
|
||||||
|
public readonly ModuleManager $modules,
|
||||||
|
public readonly array $module
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function settings(): array
|
||||||
|
{
|
||||||
|
return $this->modules->settings((string)$this->module['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/App/ModuleMigrationService.php
Normal file
172
src/App/ModuleMigrationService.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class ModuleMigrationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private \PDO $basePdo,
|
||||||
|
private ModuleManager $modules
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function status(array $module): array
|
||||||
|
{
|
||||||
|
$migrations = $this->discover($module);
|
||||||
|
$applied = $this->applied((string)$module['name']);
|
||||||
|
$pending = [];
|
||||||
|
$changed = [];
|
||||||
|
|
||||||
|
foreach ($migrations as $migration) {
|
||||||
|
$id = $migration['id'];
|
||||||
|
if (!isset($applied[$id])) {
|
||||||
|
$pending[] = $migration;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (($applied[$id]['checksum'] ?? '') !== $migration['checksum']) {
|
||||||
|
$changed[] = $migration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'available' => count($migrations),
|
||||||
|
'applied' => count($applied),
|
||||||
|
'pending' => $pending,
|
||||||
|
'changed' => $changed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyPending(array $module): array
|
||||||
|
{
|
||||||
|
$status = $this->status($module);
|
||||||
|
$applied = [];
|
||||||
|
$moduleName = (string)$module['name'];
|
||||||
|
$context = new ModuleMigrationContext($this->basePdo, $this->modules, $module);
|
||||||
|
|
||||||
|
foreach ($status['pending'] as $migration) {
|
||||||
|
$this->basePdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$runner = require $migration['path'];
|
||||||
|
if (is_object($runner) && method_exists($runner, 'up')) {
|
||||||
|
$runner->up($context);
|
||||||
|
} elseif (is_callable($runner)) {
|
||||||
|
$runner($context);
|
||||||
|
} else {
|
||||||
|
throw new \RuntimeException('Migration does not return a callable or object with up().');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->record($moduleName, $migration);
|
||||||
|
$this->basePdo->commit();
|
||||||
|
$applied[] = $migration;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($this->basePdo->inTransaction()) {
|
||||||
|
$this->basePdo->rollBack();
|
||||||
|
}
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Migration fehlgeschlagen: ' . $moduleName . '/' . $migration['id'] . ' - ' . $e->getMessage(),
|
||||||
|
0,
|
||||||
|
$e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($applied !== []) {
|
||||||
|
$this->recordModuleVersion($moduleName, (string)($module['version'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function discover(array $module): array
|
||||||
|
{
|
||||||
|
$path = (string)($module['path'] ?? '') . '/migrations';
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach (glob($path . '/*.php') ?: [] as $file) {
|
||||||
|
$id = basename($file, '.php');
|
||||||
|
$items[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'version' => $this->versionFromId($id),
|
||||||
|
'path' => $file,
|
||||||
|
'checksum' => hash_file('sha256', $file) ?: '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($items, static fn(array $a, array $b): int => strnatcmp($a['id'], $b['id']));
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applied(string $moduleName): array
|
||||||
|
{
|
||||||
|
$stmt = $this->basePdo->prepare(
|
||||||
|
"SELECT migration, version, checksum, applied_at
|
||||||
|
FROM nexus_module_migrations
|
||||||
|
WHERE module_name = :module
|
||||||
|
ORDER BY migration ASC"
|
||||||
|
);
|
||||||
|
$stmt->execute(['module' => $moduleName]);
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$items[(string)$row['migration']] = $row;
|
||||||
|
}
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function record(string $moduleName, array $migration): void
|
||||||
|
{
|
||||||
|
$driver = (string)$this->basePdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
|
||||||
|
if ($driver === 'pgsql' || $driver === 'sqlite') {
|
||||||
|
$sql = "INSERT INTO nexus_module_migrations (module_name, migration, version, checksum, applied_at)
|
||||||
|
VALUES (:module, :migration, :version, :checksum, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(module_name, migration) DO UPDATE SET
|
||||||
|
version = excluded.version,
|
||||||
|
checksum = excluded.checksum,
|
||||||
|
applied_at = CURRENT_TIMESTAMP";
|
||||||
|
} else {
|
||||||
|
$sql = "INSERT INTO nexus_module_migrations (module_name, migration, version, checksum, applied_at)
|
||||||
|
VALUES (:module, :migration, :version, :checksum, CURRENT_TIMESTAMP)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
version = VALUES(version),
|
||||||
|
checksum = VALUES(checksum),
|
||||||
|
applied_at = CURRENT_TIMESTAMP";
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->basePdo->prepare($sql);
|
||||||
|
$stmt->execute([
|
||||||
|
'module' => $moduleName,
|
||||||
|
'migration' => $migration['id'],
|
||||||
|
'version' => $migration['version'],
|
||||||
|
'checksum' => $migration['checksum'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordModuleVersion(string $moduleName, string $version): void
|
||||||
|
{
|
||||||
|
if ($version === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->basePdo->prepare(
|
||||||
|
"UPDATE nexus_modules
|
||||||
|
SET version = :version,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE name = :module"
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
'module' => $moduleName,
|
||||||
|
'version' => $version,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function versionFromId(string $id): string
|
||||||
|
{
|
||||||
|
if (preg_match('/(?:^|_)(\d+\.\d+\.\d+)(?:_|$)/', $id, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,11 +24,15 @@ final class KeaHostMetadataRepository
|
|||||||
owner TEXT,
|
owner TEXT,
|
||||||
location TEXT,
|
location TEXT,
|
||||||
device_type TEXT,
|
device_type TEXT,
|
||||||
|
group_name TEXT,
|
||||||
|
desired_ip TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
tags_json JSONB,
|
tags_json JSONB,
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
$this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT');
|
||||||
|
$this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +47,15 @@ final class KeaHostMetadataRepository
|
|||||||
owner TEXT,
|
owner TEXT,
|
||||||
location TEXT,
|
location TEXT,
|
||||||
device_type TEXT,
|
device_type TEXT,
|
||||||
|
group_name TEXT,
|
||||||
|
desired_ip TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
tags_json TEXT,
|
tags_json TEXT,
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
$this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT');
|
||||||
|
$this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,11 +69,16 @@ final class KeaHostMetadataRepository
|
|||||||
owner TEXT,
|
owner TEXT,
|
||||||
location TEXT,
|
location TEXT,
|
||||||
device_type TEXT,
|
device_type TEXT,
|
||||||
|
group_name TEXT,
|
||||||
|
desired_ip TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
tags_json TEXT,
|
tags_json TEXT,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT');
|
||||||
|
$this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findByHostIds(array $hostIds): array
|
public function findByHostIds(array $hostIds): array
|
||||||
@@ -84,7 +97,7 @@ final class KeaHostMetadataRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'SELECT host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, notes, tags_json, updated_at
|
'SELECT host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, notes, tags_json, updated_at
|
||||||
FROM nexus_dhcp_host_meta
|
FROM nexus_dhcp_host_meta
|
||||||
WHERE host_id IN (' . implode(', ', $placeholders) . ')'
|
WHERE host_id IN (' . implode(', ', $placeholders) . ')'
|
||||||
);
|
);
|
||||||
@@ -109,6 +122,8 @@ final class KeaHostMetadataRepository
|
|||||||
'owner' => null,
|
'owner' => null,
|
||||||
'location' => null,
|
'location' => null,
|
||||||
'device_type' => null,
|
'device_type' => null,
|
||||||
|
'group_name' => null,
|
||||||
|
'desired_ip' => null,
|
||||||
'notes' => null,
|
'notes' => null,
|
||||||
'tags' => [],
|
'tags' => [],
|
||||||
], $metadata);
|
], $metadata);
|
||||||
@@ -122,9 +137,9 @@ final class KeaHostMetadataRepository
|
|||||||
if ($driver === 'pgsql') {
|
if ($driver === 'pgsql') {
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
"INSERT INTO nexus_dhcp_host_meta (
|
"INSERT INTO nexus_dhcp_host_meta (
|
||||||
host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, notes, tags_json, updated_at
|
host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, notes, tags_json, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :notes, CAST(:tags_json AS jsonb), NOW()
|
:host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :group_name, :desired_ip, :notes, CAST(:tags_json AS jsonb), NOW()
|
||||||
)
|
)
|
||||||
ON CONFLICT (host_id) DO UPDATE SET
|
ON CONFLICT (host_id) DO UPDATE SET
|
||||||
hardware_address = EXCLUDED.hardware_address,
|
hardware_address = EXCLUDED.hardware_address,
|
||||||
@@ -134,6 +149,8 @@ final class KeaHostMetadataRepository
|
|||||||
owner = EXCLUDED.owner,
|
owner = EXCLUDED.owner,
|
||||||
location = EXCLUDED.location,
|
location = EXCLUDED.location,
|
||||||
device_type = EXCLUDED.device_type,
|
device_type = EXCLUDED.device_type,
|
||||||
|
group_name = EXCLUDED.group_name,
|
||||||
|
desired_ip = EXCLUDED.desired_ip,
|
||||||
notes = EXCLUDED.notes,
|
notes = EXCLUDED.notes,
|
||||||
tags_json = EXCLUDED.tags_json,
|
tags_json = EXCLUDED.tags_json,
|
||||||
updated_at = NOW()"
|
updated_at = NOW()"
|
||||||
@@ -141,9 +158,9 @@ final class KeaHostMetadataRepository
|
|||||||
} else {
|
} else {
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
"REPLACE INTO nexus_dhcp_host_meta (
|
"REPLACE INTO nexus_dhcp_host_meta (
|
||||||
host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, notes, tags_json, updated_at
|
host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, notes, tags_json, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :notes, :tags_json, CURRENT_TIMESTAMP
|
:host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :group_name, :desired_ip, :notes, :tags_json, CURRENT_TIMESTAMP
|
||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -157,11 +174,85 @@ final class KeaHostMetadataRepository
|
|||||||
'owner' => $this->nullableString($metadata['owner']),
|
'owner' => $this->nullableString($metadata['owner']),
|
||||||
'location' => $this->nullableString($metadata['location']),
|
'location' => $this->nullableString($metadata['location']),
|
||||||
'device_type' => $this->nullableString($metadata['device_type']),
|
'device_type' => $this->nullableString($metadata['device_type']),
|
||||||
|
'group_name' => $this->nullableString($metadata['group_name']),
|
||||||
|
'desired_ip' => $this->nullableString($metadata['desired_ip']),
|
||||||
'notes' => $this->nullableString($metadata['notes']),
|
'notes' => $this->nullableString($metadata['notes']),
|
||||||
'tags_json' => $tagsJson,
|
'tags_json' => $tagsJson,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listGroups(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->query(
|
||||||
|
"SELECT DISTINCT group_name
|
||||||
|
FROM nexus_dhcp_host_meta
|
||||||
|
WHERE group_name IS NOT NULL AND group_name <> ''
|
||||||
|
ORDER BY group_name ASC"
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
static fn(array $row): string => (string)($row['group_name'] ?? ''),
|
||||||
|
$stmt->fetchAll(PDO::FETCH_ASSOC)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureColumn(string $table, string $column, string $definition): void
|
||||||
|
{
|
||||||
|
if ($this->columnExists($table, $column)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||||
|
$quote = $driver === 'mysql' ? '`' : '"';
|
||||||
|
try {
|
||||||
|
$this->pdo->exec(
|
||||||
|
'ALTER TABLE ' . $quote . $table . $quote
|
||||||
|
. ' ADD COLUMN ' . $quote . $column . $quote . ' ' . $definition
|
||||||
|
);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
if (!$this->columnExists($table, $column)) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function columnExists(string $table, string $column): bool
|
||||||
|
{
|
||||||
|
$driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||||
|
|
||||||
|
if ($driver === 'sqlite') {
|
||||||
|
$stmt = $this->pdo->query('PRAGMA table_info(' . $table . ')');
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
if (($row['name'] ?? '') === $column) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($driver === 'pgsql') {
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_name = :table
|
||||||
|
AND column_name = :column
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = :table
|
||||||
|
AND column_name = :column
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$stmt->execute(['table' => $table, 'column' => $column]);
|
||||||
|
return (bool)$stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
private function nullableString(mixed $value): ?string
|
private function nullableString(mixed $value): ?string
|
||||||
{
|
{
|
||||||
$value = trim((string)$value);
|
$value = trim((string)$value);
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ final class KeaHostRepository
|
|||||||
{$ipExpr} AS ipv4_address_text,
|
{$ipExpr} AS ipv4_address_text,
|
||||||
hostname,
|
hostname,
|
||||||
user_context,
|
user_context,
|
||||||
|
dhcp4_subnet_id AS subnet_id,
|
||||||
'reservation' AS source,
|
'reservation' AS source,
|
||||||
host_id AS sort_id,
|
host_id AS sort_id,
|
||||||
{$sortExpr} AS sort_time
|
{$sortExpr} AS sort_time
|
||||||
@@ -114,6 +115,7 @@ final class KeaHostRepository
|
|||||||
{$ipExpr} AS ipv4_address_text,
|
{$ipExpr} AS ipv4_address_text,
|
||||||
hostname,
|
hostname,
|
||||||
user_context,
|
user_context,
|
||||||
|
subnet_id,
|
||||||
'lease' AS source,
|
'lease' AS source,
|
||||||
address AS sort_id,
|
address AS sort_id,
|
||||||
{$expireExpr} AS sort_time
|
{$expireExpr} AS sort_time
|
||||||
@@ -185,6 +187,7 @@ final class KeaHostRepository
|
|||||||
{$ipExpr} AS ipv4_address_text,
|
{$ipExpr} AS ipv4_address_text,
|
||||||
hostname,
|
hostname,
|
||||||
user_context,
|
user_context,
|
||||||
|
dhcp4_subnet_id AS subnet_id,
|
||||||
'reservation' AS source,
|
'reservation' AS source,
|
||||||
host_id AS sort_id,
|
host_id AS sort_id,
|
||||||
{$sortExpr} AS sort_time
|
{$sortExpr} AS sort_time
|
||||||
@@ -220,16 +223,19 @@ final class KeaHostRepository
|
|||||||
*/
|
*/
|
||||||
public function create(string $mac, string $ip, int $subnetId, ?string $hostname = null, array $metadata = []): int
|
public function create(string $mac, string $ip, int $subnetId, ?string $hostname = null, array $metadata = []): int
|
||||||
{
|
{
|
||||||
// dhcp_identifier_type 1 = HW_ADDRESS (Ethernet)
|
$macHex = $this->macToHex($mac);
|
||||||
|
$ipNumber = $this->ipv4ToNumber($ip);
|
||||||
|
$identifierExpr = $this->binaryFromHexExpression(':mac_hex');
|
||||||
|
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'INSERT INTO hosts (dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address, hostname)
|
"INSERT INTO hosts (dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address, hostname)
|
||||||
VALUES (:mac, 1, :subnetId, :ip, :hostname)'
|
VALUES ({$identifierExpr}, 1, :subnetId, :ip, :hostname)"
|
||||||
);
|
);
|
||||||
|
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
'mac' => $mac,
|
'mac_hex' => $macHex,
|
||||||
'subnetId' => $subnetId,
|
'subnetId' => $subnetId,
|
||||||
'ip' => $ip,
|
'ip' => $ipNumber,
|
||||||
'hostname' => $hostname,
|
'hostname' => $hostname,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -241,6 +247,49 @@ final class KeaHostRepository
|
|||||||
return $hostId;
|
return $hostId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reserveDisplayEntry(array $host, string $ip, array $metadata = []): int
|
||||||
|
{
|
||||||
|
$source = (string)($host['source'] ?? 'reservation');
|
||||||
|
$subnetId = (int)($host['subnet_id'] ?? 0);
|
||||||
|
if ($subnetId <= 0) {
|
||||||
|
throw new \RuntimeException('Subnet-ID fehlt. Ohne Subnet kann keine KEA-Reservierung angelegt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostname = (string)($host['hostname'] ?? '');
|
||||||
|
if ($source === 'reservation') {
|
||||||
|
$hostId = (int)($host['host_id'] ?? 0);
|
||||||
|
if ($hostId <= 0) {
|
||||||
|
throw new \RuntimeException('Reservierung hat keine gueltige Host-ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'UPDATE hosts
|
||||||
|
SET ipv4_address = :ip, hostname = :hostname, dhcp4_subnet_id = :subnet_id
|
||||||
|
WHERE host_id = :host_id'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
'ip' => $this->ipv4ToNumber($ip),
|
||||||
|
'hostname' => $hostname !== '' ? $hostname : null,
|
||||||
|
'subnet_id' => $subnetId,
|
||||||
|
'host_id' => $hostId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($this->metadata !== null) {
|
||||||
|
$this->metadata->saveForHost($hostId, (string)($host['dhcp_identifier'] ?? ''), $ip, $metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hostId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->create(
|
||||||
|
(string)($host['dhcp_identifier'] ?? ''),
|
||||||
|
$ip,
|
||||||
|
$subnetId,
|
||||||
|
$hostname !== '' ? $hostname : null,
|
||||||
|
$metadata
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function withMetadata(array $hosts): array
|
private function withMetadata(array $hosts): array
|
||||||
{
|
{
|
||||||
if ($hosts === [] || $this->metadata === null) {
|
if ($hosts === [] || $this->metadata === null) {
|
||||||
@@ -323,11 +372,50 @@ final class KeaHostRepository
|
|||||||
$row['dhcp_identifier'] = $this->formatMac((string)($row['dhcp_identifier_hex'] ?? ''));
|
$row['dhcp_identifier'] = $this->formatMac((string)($row['dhcp_identifier_hex'] ?? ''));
|
||||||
$row['ipv4_address'] = (string)($row['ipv4_address_text'] ?? '');
|
$row['ipv4_address'] = (string)($row['ipv4_address_text'] ?? '');
|
||||||
$row['hostname'] = (string)($row['hostname'] ?? '');
|
$row['hostname'] = (string)($row['hostname'] ?? '');
|
||||||
|
$contextName = $this->nameFromUserContext((string)($row['user_context'] ?? ''));
|
||||||
|
$row['display_name'] = $row['hostname'] !== '' ? $row['hostname'] : ($contextName !== '' ? $contextName : 'Unbekannt');
|
||||||
$row['metadata'] = [];
|
$row['metadata'] = [];
|
||||||
|
|
||||||
return $row;
|
return $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function nameFromUserContext(string $userContext): string
|
||||||
|
{
|
||||||
|
if (trim($userContext) === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($userContext, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->findContextValue($decoded, ['hostname', 'host-name', 'client-hostname', 'device_name', 'name', 'label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findContextValue(array $data, array $keys): string
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (isset($data[$key]) && is_scalar($data[$key])) {
|
||||||
|
$value = trim((string)$data[$key]);
|
||||||
|
if ($value !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data as $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$found = $this->findContextValue($value, $keys);
|
||||||
|
if ($found !== '') {
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
private function formatMac(string $hex): string
|
private function formatMac(string $hex): string
|
||||||
{
|
{
|
||||||
$hex = strtolower(preg_replace('/[^a-fA-F0-9]/', '', $hex) ?? '');
|
$hex = strtolower(preg_replace('/[^a-fA-F0-9]/', '', $hex) ?? '');
|
||||||
@@ -338,6 +426,35 @@ final class KeaHostRepository
|
|||||||
return implode(':', str_split($hex, 2));
|
return implode(':', str_split($hex, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function macToHex(string $mac): string
|
||||||
|
{
|
||||||
|
$hex = strtolower(preg_replace('/[^a-fA-F0-9]/', '', $mac) ?? '');
|
||||||
|
if ($hex === '') {
|
||||||
|
throw new \RuntimeException('MAC-Adresse fehlt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function binaryFromHexExpression(string $placeholder): string
|
||||||
|
{
|
||||||
|
return match ($this->driver()) {
|
||||||
|
'pgsql' => "decode({$placeholder}, 'hex')",
|
||||||
|
'mysql' => "UNHEX({$placeholder})",
|
||||||
|
default => $placeholder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ipv4ToNumber(string $ip): string
|
||||||
|
{
|
||||||
|
$long = ip2long($ip);
|
||||||
|
if ($long === false) {
|
||||||
|
throw new \RuntimeException('Ungueltige IPv4-Adresse.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%u', $long);
|
||||||
|
}
|
||||||
|
|
||||||
private function lastInsertIdSafe(): int
|
private function lastInsertIdSafe(): int
|
||||||
{
|
{
|
||||||
$driver = $this->driver();
|
$driver = $this->driver();
|
||||||
|
|||||||
Reference in New Issue
Block a user