adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-15 02:41:09 +02:00
parent 0b555e7dd4
commit 7157c98dcb
13 changed files with 614 additions and 20 deletions

View 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();
}
};

View File

@@ -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" },

View File

@@ -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>

View File

@@ -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'));

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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),

View 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']);
}
}

View 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 '';
}
}

View File

@@ -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);

View File

@@ -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();