diff --git a/CHANGELOG.md b/CHANGELOG.md
index b29eefb..5c443fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,63 @@ Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/).
---
+## [0.9.0] - 2026-07-02
+
+### Added
+- Geräte-Tracking ohne MAC-Adresse: nmap-Hosts aus Remote-Subnetzen (kein ARP möglich) werden jetzt als `network_devices`-Einträge erfasst (Tracking per Hostname, Fallback per IP)
+- `mac_address` in `network_devices` ist jetzt nullable (mehrere NULL-Werte durch MySQL erlaubt)
+- Dashboard: Separater Warnblock „⚠️ IP-Adressen-Wechsel erkannt" mit Anzeige alter IP → neuer IP, Gerätename, MAC, Quittier-Button inkl. Notizfeld
+- Alle anderen Ereignisse auf dem Dashboard ebenfalls mit inline Notiz quittierbar
+- Geräte-Detailseite: IP-Verlauf zeigt Hinweis wenn ein Gerät mehrere verschiedene IPs hatte, alle bekannten IPs als Tags, aktuelle IP hervorgehoben
+- Globale Suche: `netbios_name` wird jetzt mitdurchsucht
+
+### Setup (einmalig)
+```
+php artisan migrate
+```
+
+---
+
+## [0.8.0] - 2026-07-02
+
+### Added
+- IP-Bemerkungen: Pro IP-Adresse je Segment eine Bemerkung hinterlegen (inline editierbar in der Scan-Detailansicht)
+- Neue Tabelle `network_ip_notes` (Segment + IP als Unique-Key)
+- Subnetz-Erkennung: Schaltfläche „🔍 Erkennen" in Segment anlegen/bearbeiten – ermittelt /24-Subnetze aus vorhandenen IP-Daten per API (`/network/detect-subnets`)
+- Export Segment als Excel (`.xlsx`) via PhpSpreadsheet – enthält alle Hosts des letzten Scans inkl. Bemerkungen
+- Export Segment als PDF via DomPDF – Querformat-Tabelle mit Hosts, Status, MAC, Hostname, Hersteller, Ping und Bemerkungen
+- Export-Buttons (📊 Excel / 📄 PDF) auf Segment-Detailseite und Scan-Detailseite
+
+### Setup (einmalig ausführen)
+```
+composer require phpoffice/phpspreadsheet barryvdh/laravel-dompdf
+php artisan migrate
+```
+
+---
+
+## [0.7.0] - 2026-07-02
+
+### Added
+- Automatischer Netzwerkscan via `nmap` pro Segment (Artisan Command `network:scan`)
+- Scan-Zyklus pro Segment einstellbar: 5 / 15 / 30 / 60 / 360 / 720 / 1440 Minuten
+- „Jetzt scannen"-Button direkt auf der Segment-Detailseite
+- nmap-Parameter pro Segment konfigurierbar (z.B. `-sn`, `-sn -p 22,80,443`)
+- Laravel Scheduler führt `network:scan` jede Minute aus und prüft fällige Segmente
+- Auto-Refresh auf Segment-Detailseite nach manuellem Scan (prüft alle 10s)
+- Chronologischer IP-Verlauf (`/network/history`) über alle Scans und Segmente
+- Filter im IP-Verlauf: IP, MAC, Hostname, Segment, Status, Datumsbereich
+- IP-Verlauf Auto-Refresh alle 60 Sekunden (ohne aktiven Filter)
+- Navigation: neuer Menüpunkt „IP-Verlauf"
+- `last_scanned_at` und `scan_interval_minutes` in Segment-Stammdaten
+
+### Setup (Cron für Scheduler)
+```
+* * * * * cd /home/arudolph/Projekte/PHP/Network-MGMT && php artisan schedule:run >> /dev/null 2>&1
+```
+
+---
+
## [0.6.0] - 2026-07-01
### Added
@@ -95,7 +152,10 @@ Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/).
- Grundlegende PHP-Projektstruktur (public/, src/, config/)
- composer.json, .gitignore, README.md
-[Unreleased]: http://localhost:3000/admin/Network-MGMT/compare/v0.6.0...HEAD
+[Unreleased]: http://localhost:3000/admin/Network-MGMT/compare/v0.9.0...HEAD
+[0.9.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.8.0...v0.9.0
+[0.8.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.7.0...v0.8.0
+[0.7.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.6.0...v0.7.0
[0.6.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.5.0...v0.6.0
[0.5.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.4.0...v0.5.0
[0.4.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.3.0...v0.4.0
diff --git a/app/Console/Commands/NetworkScanCommand.php b/app/Console/Commands/NetworkScanCommand.php
new file mode 100644
index 0000000..b418bd3
--- /dev/null
+++ b/app/Console/Commands/NetworkScanCommand.php
@@ -0,0 +1,348 @@
+argument('segment');
+ $force = $this->option('force');
+
+ if ($segmentId) {
+ $segments = NetworkSegment::where('id', $segmentId)->where('active', true)->get();
+ if ($segments->isEmpty()) {
+ $this->error("Segment #{$segmentId} nicht gefunden oder inaktiv.");
+ return Command::FAILURE;
+ }
+ } else {
+ $segments = NetworkSegment::where('active', true)->get()
+ ->filter(fn($s) => $force || $s->isScanDue());
+ }
+
+ if ($segments->isEmpty()) {
+ $this->info('Kein Segment fällig. Mit --force erzwingen.');
+ return Command::SUCCESS;
+ }
+
+ foreach ($segments as $segment) {
+ $this->info("Scanne Segment: {$segment->name} ({$segment->subnet}) ...");
+ $this->scanSegment($segment);
+ }
+
+ return Command::SUCCESS;
+ }
+
+ private function scanSegment(NetworkSegment $segment): void
+ {
+ // Subnetz aus "192.168.86.0/24" extrahieren, oder direkt als Range nutzen
+ $target = $segment->subnet;
+ $options = $segment->nmap_options ?: '-sn';
+
+ // nmap im XML-Modus ausführen
+ $cmd = "nmap {$options} -oX - " . escapeshellarg($target) . " 2>/dev/null";
+ $xmlOutput = shell_exec($cmd);
+
+ if (empty($xmlOutput)) {
+ $this->warn(" nmap lieferte keine Ausgabe. Ist nmap installiert? (sudo pacman -S nmap)");
+ Log::warning("NetworkScan: nmap gab keine Ausgabe für Segment {$segment->id}");
+ return;
+ }
+
+ $hosts = $this->parseNmapXml($xmlOutput);
+ $this->info(" {$hosts['online']} online von " . count($hosts['rows']) . " gefunden.");
+
+ DB::transaction(function () use ($segment, $hosts, $target) {
+ $scan = NetworkScan::create([
+ 'segment_id' => $segment->id,
+ 'subnet' => $target,
+ 'source' => 'auto',
+ 'scanner' => 'nmap',
+ 'total_hosts' => count($hosts['rows']),
+ 'online_hosts' => $hosts['online'],
+ 'new_devices' => 0,
+ 'changed_devices' => 0,
+ 'created_by' => null,
+ ]);
+
+ $newDevices = 0;
+ $changedDevices = 0;
+
+ foreach ($hosts['rows'] as $row) {
+ $hostRecord = NetworkHost::create([
+ 'scan_id' => $scan->id,
+ 'ip_address' => $row['ip'],
+ 'mac_address' => $row['mac'] ?: null,
+ 'hostname' => $row['hostname'] ?: null,
+ 'mac_vendor' => $row['vendor'] ?: null,
+ 'status' => $row['status'],
+ 'ping_ms' => null,
+ 'ttl' => null,
+ ]);
+
+ if (!empty($row['mac'])) {
+ [$newDevices, $changedDevices] = $this->processDevice(
+ $hostRecord, $row, $scan->id, $newDevices, $changedDevices
+ );
+ } elseif ($row['status'] === 'online') {
+ // Kein MAC (Remote-Subnet / kein Root-ARP) → Tracking per Hostname/IP
+ [$newDevices, $changedDevices] = $this->processDeviceWithoutMac(
+ $hostRecord, $row, $scan->id, $newDevices, $changedDevices
+ );
+ }
+ }
+
+ $scan->update([
+ 'new_devices' => $newDevices,
+ 'changed_devices' => $changedDevices,
+ ]);
+ });
+
+ $segment->update(['last_scanned_at' => now()]);
+ $this->info(" ✓ Scan gespeichert.");
+ }
+
+ private function parseNmapXml(string $xml): array
+ {
+ $rows = [];
+ $online = 0;
+
+ try {
+ $doc = new \SimpleXMLElement($xml);
+ } catch (\Exception $e) {
+ Log::error('NetworkScan: nmap XML parse error: ' . $e->getMessage());
+ return ['rows' => [], 'online' => 0];
+ }
+
+ foreach ($doc->host as $host) {
+ $status = (string) $host->status['state']; // up | down
+ $ip = '';
+ $mac = '';
+ $vendor = '';
+
+ foreach ($host->address as $addr) {
+ $type = (string) $addr['addrtype'];
+ if ($type === 'ipv4') {
+ $ip = (string) $addr['addr'];
+ } elseif ($type === 'mac') {
+ $mac = $this->normalizeMac((string) $addr['addr']);
+ $vendor = (string) $addr['vendor'];
+ }
+ }
+
+ $hostname = '';
+ foreach ($host->hostnames->hostname ?? [] as $hn) {
+ if ((string) $hn['type'] === 'PTR') {
+ $hostname = (string) $hn['name'];
+ break;
+ }
+ }
+
+ if ($status === 'up') {
+ $online++;
+ }
+
+ if ($ip) {
+ $rows[] = [
+ 'ip' => $ip,
+ 'mac' => $mac,
+ 'vendor' => $vendor,
+ 'hostname' => $hostname,
+ 'status' => $status === 'up' ? 'online' : 'offline',
+ ];
+ }
+ }
+
+ return ['rows' => $rows, 'online' => $online];
+ }
+
+ private function processDevice(
+ NetworkHost $hostRecord,
+ array $row,
+ int $scanId,
+ int $newDevices,
+ int $changedDevices
+ ): array {
+ $device = NetworkDevice::firstOrNew(['mac_address' => $row['mac']]);
+ $isNew = !$device->exists;
+
+ if ($isNew) {
+ $device->fill([
+ 'current_ip' => $row['ip'],
+ 'hostname' => $row['hostname'] ?: null,
+ 'mac_vendor' => $row['vendor'] ?: null,
+ 'status' => $row['status'],
+ 'first_seen_at' => now(),
+ 'last_seen_at' => now(),
+ ])->save();
+
+ NetworkDeviceEvent::create([
+ 'device_id' => $device->id,
+ 'scan_id' => $scanId,
+ 'event_type' => 'new_device',
+ 'new_value' => $row['ip'],
+ 'description' => "Erstes Erscheinen: {$row['ip']}" . ($row['vendor'] ? " ({$row['vendor']})" : ''),
+ ]);
+ $newDevices++;
+ } else {
+ $events = [];
+
+ if ($device->current_ip !== $row['ip']) {
+ $events[] = [
+ 'event_type' => 'ip_changed',
+ 'old_value' => $device->current_ip,
+ 'new_value' => $row['ip'],
+ 'description' => "IP geändert: {$device->current_ip} → {$row['ip']}",
+ ];
+ $changedDevices++;
+ }
+
+ if ($device->status !== $row['status']) {
+ $events[] = [
+ 'event_type' => $row['status'] === 'online' ? 'came_online' : 'went_offline',
+ 'old_value' => $device->status,
+ 'new_value' => $row['status'],
+ 'description' => $row['status'] === 'online'
+ ? "Gerät wieder online ({$row['ip']})"
+ : "Gerät offline ({$device->current_ip})",
+ ];
+ }
+
+ $device->update([
+ 'current_ip' => $row['ip'],
+ 'hostname' => $row['hostname'] ?: $device->hostname,
+ 'status' => $row['status'],
+ 'last_seen_at' => now(),
+ ]);
+
+ foreach ($events as $event) {
+ NetworkDeviceEvent::create(array_merge($event, [
+ 'device_id' => $device->id,
+ 'scan_id' => $scanId,
+ ]));
+ }
+ }
+
+ $hostRecord->update(['device_id' => $device->id]);
+
+ return [$newDevices, $changedDevices];
+ }
+
+ /**
+ * Geräte ohne MAC-Adresse tracken (z.B. Remote-Subnet wo kein ARP möglich ist).
+ * Primärschlüssel: Hostname (stabil per DNS). Fallback: IP-Adresse.
+ */
+ private function processDeviceWithoutMac(
+ NetworkHost $hostRecord,
+ array $row,
+ int $scanId,
+ int $newDevices,
+ int $changedDevices
+ ): array {
+ // Vorhandenes Gerät suchen: erst per Hostname, dann per IP
+ $device = null;
+
+ if (!empty($row['hostname'])) {
+ $device = NetworkDevice::whereNull('mac_address')
+ ->where('hostname', $row['hostname'])
+ ->first();
+ }
+
+ if (!$device) {
+ $device = NetworkDevice::whereNull('mac_address')
+ ->where('current_ip', $row['ip'])
+ ->first();
+ }
+
+ $isNew = !$device;
+
+ if ($isNew) {
+ $device = new NetworkDevice();
+ $device->fill([
+ 'mac_address' => null,
+ 'current_ip' => $row['ip'],
+ 'hostname' => $row['hostname'] ?: null,
+ 'mac_vendor' => null,
+ 'status' => $row['status'],
+ 'first_seen_at' => now(),
+ 'last_seen_at' => now(),
+ ])->save();
+
+ NetworkDeviceEvent::create([
+ 'device_id' => $device->id,
+ 'scan_id' => $scanId,
+ 'event_type' => 'new_device',
+ 'new_value' => $row['ip'],
+ 'description' => 'Erstes Erscheinen (kein MAC): ' . $row['ip']
+ . ($row['hostname'] ? " ({$row['hostname']})" : ''),
+ ]);
+ $newDevices++;
+ } else {
+ $events = [];
+
+ // IP-Wechsel: Hostname gleich, aber andere IP
+ if ($device->current_ip !== $row['ip']) {
+ $events[] = [
+ 'event_type' => 'ip_changed',
+ 'old_value' => $device->current_ip,
+ 'new_value' => $row['ip'],
+ 'description' => "IP geändert: {$device->current_ip} → {$row['ip']}",
+ ];
+ $changedDevices++;
+ }
+
+ if ($device->status !== $row['status']) {
+ $events[] = [
+ 'event_type' => $row['status'] === 'online' ? 'came_online' : 'went_offline',
+ 'old_value' => $device->status,
+ 'new_value' => $row['status'],
+ 'description' => $row['status'] === 'online'
+ ? "Gerät wieder online ({$row['ip']})"
+ : "Gerät offline ({$device->current_ip})",
+ ];
+ }
+
+ $device->update([
+ 'current_ip' => $row['ip'],
+ 'hostname' => $row['hostname'] ?: $device->hostname,
+ 'status' => $row['status'],
+ 'last_seen_at' => now(),
+ ]);
+
+ foreach ($events as $event) {
+ NetworkDeviceEvent::create(array_merge($event, [
+ 'device_id' => $device->id,
+ 'scan_id' => $scanId,
+ ]));
+ }
+ }
+
+ $hostRecord->update(['device_id' => $device->id]);
+
+ return [$newDevices, $changedDevices];
+ }
+
+ private function normalizeMac(string $mac): string
+ {
+ $clean = preg_replace('/[^a-fA-F0-9]/', '', $mac);
+ if (strlen($clean) !== 12) {
+ return '';
+ }
+ return strtoupper(implode(':', str_split($clean, 2)));
+ }
+}
diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php
index a5234f0..65e63e2 100644
--- a/app/Http/Controllers/NetworkController.php
+++ b/app/Http/Controllers/NetworkController.php
@@ -4,26 +4,53 @@ namespace App\Http\Controllers;
use App\Models\NetworkDevice;
use App\Models\NetworkDeviceEvent;
+use App\Models\NetworkHost;
use App\Models\NetworkScan;
use App\Models\NetworkSegment;
use App\Services\NetworkScanImporter;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class NetworkController extends Controller
{
+ // --- Subnetz-Erkennung aus vorhandenen IP-Daten ---
+ public function detectSubnets(): JsonResponse
+ {
+ $subnets = DB::table('network_hosts')
+ ->whereNotNull('ip_address')
+ ->select(DB::raw("CONCAT(SUBSTRING_INDEX(ip_address, '.', 3), '.0/24') as subnet"))
+ ->distinct()
+ ->orderBy('subnet')
+ ->pluck('subnet')
+ ->toArray();
+
+ return response()->json($subnets);
+ }
+
// --- Dashboard ---
public function dashboard(): View
{
$segments = NetworkSegment::withCount('scans')->orderBy('name')->get();
$totalDevices = NetworkDevice::count();
$onlineDevices = NetworkDevice::where('status', 'online')->count();
- $recentEvents = NetworkDeviceEvent::with('device')
+
+ // IP-Wechsel separat → prominenter Block oben
+ $ipChangeEvents = NetworkDeviceEvent::with('device')
->where('documented', false)
+ ->where('event_type', 'ip_changed')
->latest()
- ->take(10)
+ ->get();
+
+ // Alle anderen undokumentierten Ereignisse
+ $recentEvents = NetworkDeviceEvent::with('device')
+ ->where('documented', false)
+ ->where('event_type', '!=', 'ip_changed')
+ ->latest()
+ ->take(15)
->get();
// Letzten Scan pro Segment
@@ -33,31 +60,113 @@ class NetworkController extends Controller
->groupBy('segment_id');
return view('network.dashboard', compact(
- 'segments', 'totalDevices', 'onlineDevices', 'recentEvents', 'latestScans'
+ 'segments', 'totalDevices', 'onlineDevices',
+ 'recentEvents', 'ipChangeEvents', 'latestScans'
));
}
+ // --- Chronologischer IP-Verlauf ---
+ public function history(Request $request): View
+ {
+ $query = \App\Models\NetworkHost::query()
+ ->join('network_scans', 'network_scans.id', '=', 'network_hosts.scan_id')
+ ->leftJoin('network_segments', 'network_segments.id', '=', 'network_scans.segment_id')
+ ->leftJoin('network_devices', 'network_devices.id', '=', 'network_hosts.device_id')
+ ->select([
+ 'network_hosts.*',
+ 'network_scans.created_at as scan_time',
+ 'network_scans.segment_id',
+ 'network_scans.scanner',
+ 'network_segments.name as segment_name',
+ 'network_devices.label as device_label',
+ ]);
+
+ if ($request->filled('ip')) {
+ $query->where('network_hosts.ip_address', 'like', '%' . $request->ip . '%');
+ }
+ if ($request->filled('mac')) {
+ $query->where('network_hosts.mac_address', 'like', '%' . $request->mac . '%');
+ }
+ if ($request->filled('hostname')) {
+ $query->where(function ($q) use ($request) {
+ $q->where('network_hosts.hostname', 'like', '%' . $request->hostname . '%')
+ ->orWhere('network_devices.label', 'like', '%' . $request->hostname . '%');
+ });
+ }
+ if ($request->filled('segment')) {
+ $query->where('network_scans.segment_id', $request->segment);
+ }
+ if ($request->filled('status')) {
+ $query->where('network_hosts.status', $request->status);
+ }
+ if ($request->filled('from')) {
+ $query->where('network_scans.created_at', '>=', $request->from . ' 00:00:00');
+ }
+ if ($request->filled('to')) {
+ $query->where('network_scans.created_at', '<=', $request->to . ' 23:59:59');
+ }
+
+ $entries = $query->orderByDesc('network_scans.created_at')
+ ->orderByRaw('INET_ATON(network_hosts.ip_address)')
+ ->paginate(100)
+ ->withQueryString();
+
+ $segments = \App\Models\NetworkSegment::orderBy('name')->get();
+
+ return view('network.history', compact('entries', 'segments'));
+ }
+
// --- Globale Suche ---
public function search(Request $request): View
{
$q = trim($request->get('q', ''));
$devices = collect();
+ $hostResults = collect();
if (strlen($q) >= 2) {
+ // Suche in network_devices (MAC-basiert / bereits getracked)
$devices = NetworkDevice::with('events')
->where(function ($query) use ($q) {
- $query->where('current_ip', 'like', "%{$q}%")
+ $query->where('current_ip', 'like', "%{$q}%")
->orWhere('mac_address', 'like', "%{$q}%")
->orWhere('hostname', 'like', "%{$q}%")
+ ->orWhere('netbios_name','like', "%{$q}%")
->orWhere('label', 'like', "%{$q}%")
->orWhere('mac_vendor', 'like', "%{$q}%");
})
->orderBy('current_ip')
->paginate(50)
->withQueryString();
+
+ // Zusätzlich: direkte Suche in network_hosts (auch ohne Device-Eintrag)
+ // Zeigt IPs/Hosts die in Scans gefunden wurden, aber noch kein Device-Record haben
+ $hostResults = NetworkHost::query()
+ ->join('network_scans', 'network_scans.id', '=', 'network_hosts.scan_id')
+ ->leftJoin('network_segments', 'network_segments.id', '=', 'network_scans.segment_id')
+ ->whereNull('network_hosts.device_id')
+ ->where(function ($query) use ($q) {
+ $query->where('network_hosts.ip_address', 'like', "%{$q}%")
+ ->orWhere('network_hosts.mac_address', 'like', "%{$q}%")
+ ->orWhere('network_hosts.hostname', 'like', "%{$q}%");
+ })
+ ->select([
+ 'network_hosts.ip_address',
+ 'network_hosts.mac_address',
+ 'network_hosts.hostname',
+ 'network_hosts.mac_vendor',
+ 'network_hosts.status',
+ 'network_hosts.ping_ms',
+ 'network_scans.created_at as scan_time',
+ 'network_segments.name as segment_name',
+ DB::raw('network_scans.id as scan_id'),
+ ])
+ ->orderByDesc('network_scans.created_at')
+ ->limit(50)
+ ->get()
+ ->unique('ip_address'); // Jede IP nur einmal (neuester Fund)
}
- return view('network.search', compact('q', 'devices'));
+ return view('network.search', compact('q', 'devices', 'hostResults'));
}
// --- Alte index-Route umleiten ---
@@ -203,6 +312,12 @@ class NetworkController extends Controller
->orderByRaw("INET_ATON(ip_address)")
->get();
- return view('network.scan', compact('scan', 'hosts'));
+ $notes = collect();
+ if ($scan->segment_id) {
+ $notes = \App\Models\NetworkIpNote::where('segment_id', $scan->segment_id)
+ ->pluck('note', 'ip_address');
+ }
+
+ return view('network.scan', compact('scan', 'hosts', 'notes'));
}
}
diff --git a/app/Http/Controllers/NetworkSegmentController.php b/app/Http/Controllers/NetworkSegmentController.php
index cab113f..d21a3c8 100644
--- a/app/Http/Controllers/NetworkSegmentController.php
+++ b/app/Http/Controllers/NetworkSegmentController.php
@@ -2,9 +2,13 @@
namespace App\Http\Controllers;
+use App\Models\NetworkIpNote;
use App\Models\NetworkSegment;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class NetworkSegmentController extends Controller
@@ -80,4 +84,96 @@ class NetworkSegmentController extends Controller
return redirect()->route('network.segments.index')
->with('success', "Segment \"{$name}\" gelöscht.");
}
+
+ public function triggerScan(NetworkSegment $segment): RedirectResponse
+ {
+ if (!$segment->active) {
+ return back()->with('error', 'Segment ist inaktiv.');
+ }
+
+ // Scan im Hintergrund starten (non-blocking)
+ $projectPath = base_path();
+ $phpBinary = PHP_BINARY;
+ $cmd = "cd {$projectPath} && {$phpBinary} artisan network:scan {$segment->id} --force > /dev/null 2>&1 &";
+ exec($cmd);
+
+ return redirect()->route('network.segments.show', $segment)
+ ->with('success', "Scan für \"{$segment->name}\" wurde gestartet. Ergebnisse erscheinen in Kürze.");
+ }
+
+ // --- IP-Notiz speichern (Upsert per Segment + IP) ---
+ public function saveIpNote(Request $request, NetworkSegment $segment): JsonResponse
+ {
+ $request->validate([
+ 'ip_address' => ['required', 'ip'],
+ 'note' => ['nullable', 'string', 'max:500'],
+ ]);
+
+ NetworkIpNote::updateOrCreate(
+ ['segment_id' => $segment->id, 'ip_address' => $request->ip_address],
+ [
+ 'note' => $request->note,
+ 'updated_by' => auth()->id(),
+ 'created_by' => auth()->id(),
+ ]
+ );
+
+ return response()->json(['ok' => true]);
+ }
+
+ // --- Export: Excel (OpenSpout – kein ext-gd, PHP 8.5 kompatibel) ---
+ public function exportXlsx(NetworkSegment $segment)
+ {
+ $hosts = $this->getLatestScanHosts($segment);
+ $notes = NetworkIpNote::where('segment_id', $segment->id)
+ ->pluck('note', 'ip_address');
+
+ $filename = 'Segment_' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $segment->name)
+ . '_' . now()->format('Ymd_Hi') . '.xlsx';
+ $tempFile = tempnam(sys_get_temp_dir(), 'net_export_') . '.xlsx';
+
+ $writer = new \OpenSpout\Writer\XLSX\Writer();
+ $writer->openToFile($tempFile);
+
+ $writer->addRow(\OpenSpout\Common\Entity\Row::fromValues([
+ 'IP-Adresse', 'Status', 'Hostname', 'MAC-Adresse', 'Hersteller', 'Ping (ms)', 'Bemerkung',
+ ]));
+
+ foreach ($hosts as $host) {
+ $writer->addRow(\OpenSpout\Common\Entity\Row::fromValues([
+ $host->ip_address,
+ $host->status,
+ $host->hostname ?? '',
+ $host->mac_address ?? '',
+ $host->mac_vendor ?? '',
+ $host->ping_ms !== null ? (string) $host->ping_ms : '',
+ $notes[$host->ip_address] ?? '',
+ ]));
+ }
+
+ $writer->close();
+
+ return response()->download($tempFile, $filename, [
+ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ ])->deleteFileAfterSend(true);
+ }
+
+ // --- Export: PDF (Browser-Print-View – kein PHP-PDF-Package nötig) ---
+ public function exportPdf(NetworkSegment $segment)
+ {
+ $hosts = $this->getLatestScanHosts($segment);
+ $notes = NetworkIpNote::where('segment_id', $segment->id)
+ ->pluck('note', 'ip_address');
+
+ return view('network.segments.export-pdf', compact('segment', 'hosts', 'notes'));
+ }
+
+ private function getLatestScanHosts(NetworkSegment $segment)
+ {
+ $latestScan = $segment->scans()->latest()->first();
+ if (!$latestScan) {
+ return collect();
+ }
+ return $latestScan->hosts()->orderByRaw('INET_ATON(ip_address)')->get();
+ }
}
diff --git a/app/Models/NetworkIpNote.php b/app/Models/NetworkIpNote.php
new file mode 100644
index 0000000..be31f66
--- /dev/null
+++ b/app/Models/NetworkIpNote.php
@@ -0,0 +1,22 @@
+belongsTo(NetworkSegment::class);
+ }
+}
diff --git a/app/Models/NetworkSegment.php b/app/Models/NetworkSegment.php
index eb7232f..4b220f4 100644
--- a/app/Models/NetworkSegment.php
+++ b/app/Models/NetworkSegment.php
@@ -10,14 +10,44 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
class NetworkSegment extends Model
{
protected $fillable = [
- 'name', 'subnet', 'vlan_id', 'active', 'description', 'created_by',
+ 'name', 'subnet', 'vlan_id', 'active', 'description',
+ 'scan_interval_minutes', 'last_scanned_at', 'nmap_options', 'created_by',
];
protected $casts = [
- 'active' => 'boolean',
- 'vlan_id' => 'integer',
+ 'active' => 'boolean',
+ 'vlan_id' => 'integer',
+ 'scan_interval_minutes' => 'integer',
+ 'last_scanned_at' => 'datetime',
];
+ /** Ist ein automatischer Scan fällig? */
+ public function isScanDue(): bool
+ {
+ if (!$this->active || !$this->scan_interval_minutes) {
+ return false;
+ }
+ if (!$this->last_scanned_at) {
+ return true;
+ }
+ return $this->last_scanned_at->addMinutes($this->scan_interval_minutes)->isPast();
+ }
+
+ /** Lesbare Intervall-Beschreibung */
+ public function getScanIntervalLabelAttribute(): string
+ {
+ return match($this->scan_interval_minutes) {
+ 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',
+ default => 'manuell',
+ };
+ }
+
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
diff --git a/composer.json b/composer.json
index 0724ca3..6d585c9 100644
--- a/composer.json
+++ b/composer.json
@@ -9,6 +9,7 @@
"php": "^8.3",
"laravel/framework": "^13.8",
"laravel/tinker": "^3.0",
+ "openspout/openspout": "^5.7",
"spatie/laravel-permission": "^8.0"
},
"require-dev": {
diff --git a/composer.lock b/composer.lock
index 41de798..9d9a9aa 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "8e2e89c33485082dd08a993fad67945d",
+ "content-hash": "a17f728f96acdb49759b28983a8e062a",
"packages": [
{
"name": "brick/math",
@@ -2540,6 +2540,99 @@
],
"time": "2026-02-16T23:10:27+00:00"
},
+ {
+ "name": "openspout/openspout",
+ "version": "v5.7.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/openspout/openspout.git",
+ "reference": "f383ae8ab4c735b6a6a0cef396e9799900584f3e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/openspout/openspout/zipball/f383ae8ab4c735b6a6a0cef396e9799900584f3e",
+ "reference": "f383ae8ab4c735b6a6a0cef396e9799900584f3e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-filter": "*",
+ "ext-libxml": "*",
+ "ext-xmlreader": "*",
+ "ext-zip": "*",
+ "php": "~8.4.0 || ~8.5.0"
+ },
+ "require-dev": {
+ "ext-fileinfo": "*",
+ "ext-zlib": "*",
+ "friendsofphp/php-cs-fixer": "^3.95.2",
+ "infection/infection": "^0.33.2",
+ "phpbench/phpbench": "^1.6.1",
+ "phpstan/phpstan": "^2.2.1",
+ "phpstan/phpstan-phpunit": "^2.0.16",
+ "phpstan/phpstan-strict-rules": "^2.0.11",
+ "phpunit/phpunit": "^13.1.13"
+ },
+ "suggest": {
+ "ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)",
+ "ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "OpenSpout\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Adrien Loison",
+ "email": "adrien@box.com"
+ }
+ ],
+ "description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way",
+ "homepage": "https://github.com/openspout/openspout",
+ "keywords": [
+ "OOXML",
+ "csv",
+ "excel",
+ "memory",
+ "odf",
+ "ods",
+ "office",
+ "open",
+ "php",
+ "read",
+ "scale",
+ "spreadsheet",
+ "stream",
+ "write",
+ "xlsx"
+ ],
+ "support": {
+ "issues": "https://github.com/openspout/openspout/issues",
+ "source": "https://github.com/openspout/openspout/tree/v5.7.2"
+ },
+ "funding": [
+ {
+ "url": "https://paypal.me/filippotessarotto",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/Slamdunk",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-29T11:43:33+00:00"
+ },
{
"name": "phpoption/phpoption",
"version": "1.9.5",
diff --git a/database/migrations/2026_07_01_100003_add_scan_config_to_network_segments.php b/database/migrations/2026_07_01_100003_add_scan_config_to_network_segments.php
new file mode 100644
index 0000000..01e53c4
--- /dev/null
+++ b/database/migrations/2026_07_01_100003_add_scan_config_to_network_segments.php
@@ -0,0 +1,27 @@
+unsignedInteger('scan_interval_minutes')->nullable()->after('active');
+ // Zeitpunkt des letzten automatischen Scans
+ $table->timestamp('last_scanned_at')->nullable()->after('scan_interval_minutes');
+ // Optionale nmap-Parameter, z.B. "-sn" oder "-sn -p 22,80,443"
+ $table->string('nmap_options')->default('-sn')->after('last_scanned_at');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('network_segments', function (Blueprint $table) {
+ $table->dropColumn(['scan_interval_minutes', 'last_scanned_at', 'nmap_options']);
+ });
+ }
+};
diff --git a/database/migrations/2026_07_02_100001_create_network_ip_notes_table.php b/database/migrations/2026_07_02_100001_create_network_ip_notes_table.php
new file mode 100644
index 0000000..338de18
--- /dev/null
+++ b/database/migrations/2026_07_02_100001_create_network_ip_notes_table.php
@@ -0,0 +1,29 @@
+id();
+ $table->unsignedBigInteger('segment_id')->nullable()->index();
+ $table->string('ip_address', 45)->index();
+ $table->text('note')->nullable();
+ $table->unsignedBigInteger('created_by')->nullable();
+ $table->unsignedBigInteger('updated_by')->nullable();
+ $table->timestamps();
+
+ $table->unique(['segment_id', 'ip_address']);
+ $table->foreign('segment_id')->references('id')->on('network_segments')->onDelete('cascade');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('network_ip_notes');
+ }
+};
diff --git a/database/migrations/2026_07_02_200001_make_mac_address_nullable_in_network_devices.php b/database/migrations/2026_07_02_200001_make_mac_address_nullable_in_network_devices.php
new file mode 100644
index 0000000..4b0ecae
--- /dev/null
+++ b/database/migrations/2026_07_02_200001_make_mac_address_nullable_in_network_devices.php
@@ -0,0 +1,23 @@
+
🔍 Suche
+
Offene Ereignisse
-{{ $recentEvents->count() }}
++ {{ $recentEvents->count() + $ipChangeEvents->count() }} +