v0.9.0: no-MAC device tracking, IP-change dashboard, extended search

This commit is contained in:
2026-07-02 20:37:57 +02:00
parent 9fa20af87a
commit 85118c5bcc
23 changed files with 1703 additions and 48 deletions
@@ -22,9 +22,16 @@
<div>
<x-input-label for="subnet" value="Subnetz *" />
<x-text-input id="subnet" name="subnet" type="text" class="mt-1 block w-full font-mono"
value="{{ old('subnet') }}" placeholder="z.B. 192.168.1.0/24 oder 10.0.0.0/8" required />
<div class="flex gap-2 mt-1">
<x-text-input id="subnet" name="subnet" type="text" class="block w-full font-mono"
value="{{ old('subnet') }}" placeholder="z.B. 192.168.1.0/24 oder 10.0.0.0/8" required />
<button type="button" id="detect-subnet-btn"
class="shrink-0 px-3 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
🔍 Erkennen
</button>
</div>
<x-input-error :messages="$errors->get('subnet')" class="mt-1" />
<div id="subnet-suggestions" class="mt-1 hidden"></div>
</div>
<div>
@@ -41,6 +48,29 @@
placeholder="Kurze Beschreibung dieses Segments...">{{ old('description') }}</textarea>
</div>
<div>
<x-input-label for="scan_interval_minutes" value="Scan-Zyklus (automatisch)" />
<select id="scan_interval_minutes" name="scan_interval_minutes"
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Manuell (kein Auto-Scan)</option>
<option value="5" {{ old('scan_interval_minutes') == '5' ? 'selected' : '' }}>Alle 5 Minuten</option>
<option value="15" {{ old('scan_interval_minutes') == '15' ? 'selected' : '' }}>Alle 15 Minuten</option>
<option value="30" {{ old('scan_interval_minutes') == '30' ? 'selected' : '' }}>Alle 30 Minuten</option>
<option value="60" {{ old('scan_interval_minutes') == '60' ? 'selected' : '' }}>Stündlich</option>
<option value="360" {{ old('scan_interval_minutes') == '360' ? 'selected' : '' }}>Alle 6 Stunden</option>
<option value="720" {{ old('scan_interval_minutes') == '720' ? 'selected' : '' }}>Alle 12 Stunden</option>
<option value="1440" {{ old('scan_interval_minutes') == '1440' ? 'selected' : '' }}>Täglich</option>
</select>
<p class="mt-1 text-xs text-gray-500">Erfordert aktiven Laravel Scheduler (Cron).</p>
</div>
<div>
<x-input-label for="nmap_options" value="nmap-Parameter" />
<x-text-input id="nmap_options" name="nmap_options" type="text" class="mt-1 block w-full font-mono"
value="{{ old('nmap_options', '-sn') }}" placeholder="-sn" />
<p class="mt-1 text-xs text-gray-500">-sn = Ping-Scan (schnell) · -sn -p 22,80,443 = mit Ports</p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="active" name="active" value="1"
{{ old('active', '1') ? 'checked' : '' }}
@@ -62,4 +92,38 @@
</div>
</div>
</div>
<script>
document.getElementById('detect-subnet-btn').addEventListener('click', function() {
const btn = this;
const suggestions = document.getElementById('subnet-suggestions');
btn.textContent = '⏳ Suche...';
btn.disabled = true;
fetch('{{ route('network.detect-subnets') }}', {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(function(subnets) {
btn.textContent = '🔍 Erkennen';
btn.disabled = false;
if (!subnets.length) {
suggestions.innerHTML = '<p class="text-xs text-gray-400">Keine Daten in der Datenbank gefunden.</p>';
suggestions.classList.remove('hidden');
return;
}
suggestions.innerHTML = '<p class="text-xs text-gray-500 mb-1">Erkannte Subnetze klicken zum Übernehmen:</p>' +
subnets.map(function(s) {
return '<button type="button" onclick="document.getElementById(\'subnet\').value=\'' + s + '\'" ' +
'class="mr-1 mb-1 px-2 py-1 text-xs font-mono rounded border border-indigo-300 text-indigo-700 hover:bg-indigo-50 transition">' + s + '</button>';
}).join('');
suggestions.classList.remove('hidden');
})
.catch(function() {
btn.textContent = '🔍 Erkennen';
btn.disabled = false;
suggestions.innerHTML = '<p class="text-xs text-red-500">Fehler bei der Erkennung.</p>';
suggestions.classList.remove('hidden');
});
});
</script>
</x-app-layout>
@@ -22,9 +22,16 @@
<div>
<x-input-label for="subnet" value="Subnetz *" />
<x-text-input id="subnet" name="subnet" type="text" class="mt-1 block w-full font-mono"
value="{{ old('subnet', $segment->subnet) }}" required />
<div class="flex gap-2 mt-1">
<x-text-input id="subnet" name="subnet" type="text" class="block w-full font-mono"
value="{{ old('subnet', $segment->subnet) }}" required />
<button type="button" id="detect-subnet-btn"
class="shrink-0 px-3 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
🔍 Erkennen
</button>
</div>
<x-input-error :messages="$errors->get('subnet')" class="mt-1" />
<div id="subnet-suggestions" class="mt-1 hidden"></div>
</div>
<div>
@@ -40,6 +47,26 @@
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">{{ old('description', $segment->description) }}</textarea>
</div>
<div>
<x-input-label for="scan_interval_minutes" value="Scan-Zyklus (automatisch)" />
<select id="scan_interval_minutes" name="scan_interval_minutes"
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Manuell (kein Auto-Scan)</option>
@foreach([5 => 'Alle 5 Minuten', 15 => 'Alle 15 Minuten', 30 => 'Alle 30 Minuten', 60 => 'Stündlich', 360 => 'Alle 6 Stunden', 720 => 'Alle 12 Stunden', 1440 => 'Täglich'] as $val => $label)
<option value="{{ $val }}" {{ old('scan_interval_minutes', $segment->scan_interval_minutes) == $val ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div>
<x-input-label for="nmap_options" value="nmap-Parameter" />
<x-text-input id="nmap_options" name="nmap_options" type="text" class="mt-1 block w-full font-mono"
value="{{ old('nmap_options', $segment->nmap_options) }}" placeholder="-sn" />
<p class="mt-1 text-xs text-gray-500">-sn = Ping-Scan · -sn -p 22,80,443 = mit Ports</p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="active" name="active" value="1"
{{ old('active', $segment->active) ? 'checked' : '' }}
@@ -61,4 +88,38 @@
</div>
</div>
</div>
<script>
document.getElementById('detect-subnet-btn').addEventListener('click', function() {
const btn = this;
const suggestions = document.getElementById('subnet-suggestions');
btn.textContent = '⏳ Suche...';
btn.disabled = true;
fetch('{{ route('network.detect-subnets') }}', {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(function(subnets) {
btn.textContent = '🔍 Erkennen';
btn.disabled = false;
if (!subnets.length) {
suggestions.innerHTML = '<p class="text-xs text-gray-400">Keine Daten in der Datenbank gefunden.</p>';
suggestions.classList.remove('hidden');
return;
}
suggestions.innerHTML = '<p class="text-xs text-gray-500 mb-1">Erkannte Subnetze klicken zum Übernehmen:</p>' +
subnets.map(function(s) {
return '<button type="button" onclick="document.getElementById(\'subnet\').value=\'' + s + '\'" ' +
'class="mr-1 mb-1 px-2 py-1 text-xs font-mono rounded border border-indigo-300 text-indigo-700 hover:bg-indigo-50 transition">' + s + '</button>';
}).join('');
suggestions.classList.remove('hidden');
})
.catch(function() {
btn.textContent = '🔍 Erkennen';
btn.disabled = false;
suggestions.innerHTML = '<p class="text-xs text-red-500">Fehler bei der Erkennung.</p>';
suggestions.classList.remove('hidden');
});
});
</script>
</x-app-layout>
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Segment {{ $segment->name }} Export</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, sans-serif;
font-size: 11px;
color: #1f2937;
padding: 20px;
background: white;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid #e5e7eb;
}
h1 { font-size: 18px; font-weight: bold; color: #111827; }
.meta {
font-size: 11px;
color: #6b7280;
margin-top: 4px;
line-height: 1.5;
}
.print-btn {
background: #4f46e5;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.print-btn:hover { background: #4338ca; }
table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
}
thead th {
background-color: #f3f4f6;
border: 1px solid #d1d5db;
padding: 6px 8px;
text-align: left;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
color: #6b7280;
letter-spacing: 0.05em;
}
tbody td {
border: 1px solid #e5e7eb;
padding: 5px 8px;
vertical-align: top;
}
tbody tr:nth-child(even) td { background-color: #f9fafb; }
tbody tr:hover td { background-color: #eff6ff; }
.online { color: #16a34a; font-weight: 700; }
.offline { color: #9ca3af; }
.mono { font-family: 'Courier New', monospace; }
.note { color: #4b5563; font-style: italic; }
.footer {
margin-top: 12px;
font-size: 9px;
color: #9ca3af;
text-align: right;
}
/* Print-Stile */
@media print {
.no-print { display: none !important; }
body { padding: 0; font-size: 10px; }
thead th { background-color: #f3f4f6 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
tbody tr:nth-child(even) td { background-color: #f9fafb !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@page { size: A4 landscape; margin: 1.5cm; }
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>🌐 Segment: {{ $segment->name }}</h1>
<div class="meta">
Subnetz: <strong>{{ $segment->subnet }}</strong>
@if($segment->vlan_id) &nbsp;·&nbsp; VLAN {{ $segment->vlan_id }} @endif
&nbsp;·&nbsp; {{ $hosts->count() }} Hosts
&nbsp;·&nbsp; {{ $hosts->where('status', 'online')->count() }} online
<br>
Exportiert: {{ now()->format('d.m.Y H:i') }}
@if($segment->description) &nbsp;·&nbsp; {{ $segment->description }} @endif
</div>
</div>
<button class="print-btn no-print" onclick="window.print()">🖨️ Als PDF drucken</button>
</div>
<table>
<thead>
<tr>
<th style="width:7%">Status</th>
<th style="width:12%">IP-Adresse</th>
<th style="width:16%">MAC-Adresse</th>
<th style="width:18%">Hostname</th>
<th style="width:14%">Hersteller</th>
<th style="width:7%">Ping</th>
<th>Bemerkung</th>
</tr>
</thead>
<tbody>
@forelse($hosts as $host)
<tr>
<td class="{{ $host->status === 'online' ? 'online' : 'offline' }}">
{{ $host->status === 'online' ? '● Online' : '○ Offline' }}
</td>
<td class="mono">{{ $host->ip_address }}</td>
<td class="mono">{{ $host->mac_address ?? '—' }}</td>
<td>{{ $host->hostname ?? '—' }}</td>
<td>{{ $host->mac_vendor ?? '—' }}</td>
<td class="mono">{{ $host->ping_ms !== null ? $host->ping_ms . ' ms' : '—' }}</td>
<td class="note">{{ $notes[$host->ip_address] ?? '' }}</td>
</tr>
@empty
<tr>
<td colspan="7" style="text-align:center; padding:24px; color:#9ca3af;">
Keine Hosts vorhanden.
</td>
</tr>
@endforelse
</tbody>
</table>
<div class="footer">
Network-MGMT · MMS System Service · {{ now()->format('d.m.Y H:i') }}
</div>
<script>
// Automatisch Druckdialog öffnen (nur wenn direkt als PDF aufgerufen)
if (window.location.pathname.includes('/export/pdf')) {
window.addEventListener('load', function() {
setTimeout(function() { window.print(); }, 300);
});
}
</script>
</body>
</html>
@@ -6,14 +6,37 @@
<span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">{{ $segment->name }}</h2>
</div>
<div class="flex gap-2">
<div class="flex gap-2 items-center">
{{-- Auto-Refresh Indikator --}}
<span id="refresh-indicator" class="text-xs text-gray-400 dark:text-gray-500 hidden">
wird aktualisiert...
</span>
{{-- Jetzt scannen --}}
@if($segment->active)
<form method="POST" action="{{ route('network.segments.scan', $segment) }}">
@csrf
<button type="submit" style="background-color: var(--color-primary)"
class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
Jetzt scannen
</button>
</form>
@endif
<a href="{{ route('network.import') }}?segment={{ $segment->id }}"
style="background-color: var(--color-primary)"
class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
+ Scan importieren
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
Import
</a>
<a href="{{ route('network.segments.export.xlsx', $segment) }}"
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
📊 Excel
</a>
<a href="{{ route('network.segments.export.pdf', $segment) }}"
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
📄 PDF
</a>
<a href="{{ route('network.segments.edit', $segment) }}"
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 transition">
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
Bearbeiten
</a>
</div>
@@ -39,6 +62,12 @@
{{ $segment->active ? 'Aktiv' : 'Inaktiv' }}
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Auto-Scan</p>
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100 text-sm">
{{ $segment->scan_interval_label }}
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Scans gesamt</p>
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100">{{ $scans->total() }}</p>
@@ -70,6 +99,7 @@
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Scan-Historie</h3>
</div>
<div data-scan-count="{{ $scans->total() }}"></div>
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
@@ -112,4 +142,33 @@
</div>
</div>
@if(session('success') && str_contains(session('success'), 'gestartet'))
{{-- Auto-Refresh nach "Jetzt scannen": alle 10s prüfen ob neuer Scan vorliegt --}}
<script>
(function() {
const currentScanCount = {{ $scans->total() }};
const indicator = document.getElementById('refresh-indicator');
let checks = 0;
const interval = setInterval(function() {
if (++checks > 18) { clearInterval(interval); return; } // max 3 Min
if (indicator) indicator.classList.remove('hidden');
fetch(window.location.href, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(r => r.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newCount = parseInt(doc.querySelector('[data-scan-count]')?.dataset?.scanCount || '0');
if (newCount > currentScanCount) {
clearInterval(interval);
window.location.reload();
}
})
.catch(() => {});
}, 10000);
})();
</script>
@endif
</x-app-layout>