1 Commits

23 changed files with 1703 additions and 48 deletions
+61 -1
View File
@@ -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 ## [0.6.0] - 2026-07-01
### Added ### Added
@@ -95,7 +152,10 @@ Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/).
- Grundlegende PHP-Projektstruktur (public/, src/, config/) - Grundlegende PHP-Projektstruktur (public/, src/, config/)
- composer.json, .gitignore, README.md - 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.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.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 [0.4.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.3.0...v0.4.0
+348
View File
@@ -0,0 +1,348 @@
<?php
namespace App\Console\Commands;
use App\Models\NetworkDevice;
use App\Models\NetworkDeviceEvent;
use App\Models\NetworkHost;
use App\Models\NetworkScan;
use App\Models\NetworkSegment;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class NetworkScanCommand extends Command
{
protected $signature = 'network:scan
{segment? : ID des zu scannenden Segments (leer = alle fälligen)}
{--force : Scan erzwingen, auch wenn Intervall noch nicht abgelaufen}';
protected $description = 'Führt einen nmap-Scan für ein oder alle aktiven Netzwerk-Segmente durch';
public function handle(): int
{
$segmentId = $this->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)));
}
}
+119 -4
View File
@@ -4,26 +4,53 @@ namespace App\Http\Controllers;
use App\Models\NetworkDevice; use App\Models\NetworkDevice;
use App\Models\NetworkDeviceEvent; use App\Models\NetworkDeviceEvent;
use App\Models\NetworkHost;
use App\Models\NetworkScan; use App\Models\NetworkScan;
use App\Models\NetworkSegment; use App\Models\NetworkSegment;
use App\Services\NetworkScanImporter; use App\Services\NetworkScanImporter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\View\View; use Illuminate\View\View;
class NetworkController extends Controller 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 --- // --- Dashboard ---
public function dashboard(): View public function dashboard(): View
{ {
$segments = NetworkSegment::withCount('scans')->orderBy('name')->get(); $segments = NetworkSegment::withCount('scans')->orderBy('name')->get();
$totalDevices = NetworkDevice::count(); $totalDevices = NetworkDevice::count();
$onlineDevices = NetworkDevice::where('status', 'online')->count(); $onlineDevices = NetworkDevice::where('status', 'online')->count();
// IP-Wechsel separat → prominenter Block oben
$ipChangeEvents = NetworkDeviceEvent::with('device')
->where('documented', false)
->where('event_type', 'ip_changed')
->latest()
->get();
// Alle anderen undokumentierten Ereignisse
$recentEvents = NetworkDeviceEvent::with('device') $recentEvents = NetworkDeviceEvent::with('device')
->where('documented', false) ->where('documented', false)
->where('event_type', '!=', 'ip_changed')
->latest() ->latest()
->take(10) ->take(15)
->get(); ->get();
// Letzten Scan pro Segment // Letzten Scan pro Segment
@@ -33,31 +60,113 @@ class NetworkController extends Controller
->groupBy('segment_id'); ->groupBy('segment_id');
return view('network.dashboard', compact( 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 --- // --- Globale Suche ---
public function search(Request $request): View public function search(Request $request): View
{ {
$q = trim($request->get('q', '')); $q = trim($request->get('q', ''));
$devices = collect(); $devices = collect();
$hostResults = collect();
if (strlen($q) >= 2) { if (strlen($q) >= 2) {
// Suche in network_devices (MAC-basiert / bereits getracked)
$devices = NetworkDevice::with('events') $devices = NetworkDevice::with('events')
->where(function ($query) use ($q) { ->where(function ($query) use ($q) {
$query->where('current_ip', 'like', "%{$q}%") $query->where('current_ip', 'like', "%{$q}%")
->orWhere('mac_address', 'like', "%{$q}%") ->orWhere('mac_address', 'like', "%{$q}%")
->orWhere('hostname', 'like', "%{$q}%") ->orWhere('hostname', 'like', "%{$q}%")
->orWhere('netbios_name','like', "%{$q}%")
->orWhere('label', 'like', "%{$q}%") ->orWhere('label', 'like', "%{$q}%")
->orWhere('mac_vendor', 'like', "%{$q}%"); ->orWhere('mac_vendor', 'like', "%{$q}%");
}) })
->orderBy('current_ip') ->orderBy('current_ip')
->paginate(50) ->paginate(50)
->withQueryString(); ->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 --- // --- Alte index-Route umleiten ---
@@ -203,6 +312,12 @@ class NetworkController extends Controller
->orderByRaw("INET_ATON(ip_address)") ->orderByRaw("INET_ATON(ip_address)")
->get(); ->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'));
} }
} }
@@ -2,9 +2,13 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\NetworkIpNote;
use App\Models\NetworkSegment; use App\Models\NetworkSegment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View; use Illuminate\View\View;
class NetworkSegmentController extends Controller class NetworkSegmentController extends Controller
@@ -80,4 +84,96 @@ class NetworkSegmentController extends Controller
return redirect()->route('network.segments.index') return redirect()->route('network.segments.index')
->with('success', "Segment \"{$name}\" gelöscht."); ->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();
}
} }
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NetworkIpNote extends Model
{
protected $fillable = [
'segment_id',
'ip_address',
'note',
'created_by',
'updated_by',
];
public function segment(): BelongsTo
{
return $this->belongsTo(NetworkSegment::class);
}
}
+31 -1
View File
@@ -10,14 +10,44 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
class NetworkSegment extends Model class NetworkSegment extends Model
{ {
protected $fillable = [ 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 = [ protected $casts = [
'active' => 'boolean', 'active' => 'boolean',
'vlan_id' => 'integer', '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 public function createdBy(): BelongsTo
{ {
return $this->belongsTo(User::class, 'created_by'); return $this->belongsTo(User::class, 'created_by');
+1
View File
@@ -9,6 +9,7 @@
"php": "^8.3", "php": "^8.3",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"openspout/openspout": "^5.7",
"spatie/laravel-permission": "^8.0" "spatie/laravel-permission": "^8.0"
}, },
"require-dev": { "require-dev": {
Generated
+94 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "8e2e89c33485082dd08a993fad67945d", "content-hash": "a17f728f96acdb49759b28983a8e062a",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -2540,6 +2540,99 @@
], ],
"time": "2026-02-16T23:10:27+00:00" "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", "name": "phpoption/phpoption",
"version": "1.9.5", "version": "1.9.5",
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('network_segments', function (Blueprint $table) {
// Scan-Zyklus in Minuten: null = manuell, 5/15/30/60/360/1440
$table->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']);
});
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('network_ip_notes', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// MAC-Adresse nullable machen, damit nmap-Hosts ohne MAC als Gerät erfasst werden können.
// In MySQL/MariaDB erlaubt UNIQUE NULL mehrere NULL-Werte gleichzeitig.
DB::statement('ALTER TABLE network_devices MODIFY mac_address VARCHAR(255) NULL');
}
public function down(): void
{
// NULL-Einträge entfernen, bevor wir NOT NULL setzen
DB::statement("DELETE FROM network_devices WHERE mac_address IS NULL");
DB::statement('ALTER TABLE network_devices MODIFY mac_address VARCHAR(255) NOT NULL');
}
};
@@ -63,6 +63,9 @@
<x-dropdown-link :href="route('network.search')"> <x-dropdown-link :href="route('network.search')">
🔍 Suche 🔍 Suche
</x-dropdown-link> </x-dropdown-link>
<x-dropdown-link :href="route('network.history')">
📅 IP-Verlauf
</x-dropdown-link>
<x-dropdown-link :href="route('network.import')"> <x-dropdown-link :href="route('network.import')">
⬆️ Scan importieren ⬆️ Scan importieren
</x-dropdown-link> </x-dropdown-link>
+94 -10
View File
@@ -34,10 +34,83 @@
</div> </div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4"> <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">Offene Ereignisse</p> <p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Offene Ereignisse</p>
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">{{ $recentEvents->count() }}</p> <p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{{ $recentEvents->count() + $ipChangeEvents->count() }}
</p>
</div> </div>
</div> </div>
{{-- ⚠️ IP-Wechsel prominenter Warnblock --}}
@if($ipChangeEvents->count() > 0)
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 rounded-lg overflow-hidden">
<div class="px-5 py-3 border-b border-amber-200 dark:border-amber-700 flex items-center justify-between">
<h3 class="font-semibold text-amber-800 dark:text-amber-300 flex items-center gap-2">
⚠️ IP-Adressen-Wechsel erkannt
<span class="bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-200 text-xs px-2 py-0.5 rounded-full font-bold">
{{ $ipChangeEvents->count() }}
</span>
</h3>
<span class="text-xs text-amber-600 dark:text-amber-400">Bitte prüfen und quittieren</span>
</div>
<div class="divide-y divide-amber-100 dark:divide-amber-800">
@foreach($ipChangeEvents as $event)
<div class="px-5 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
{{-- Gerät und IP-Wechsel --}}
<div class="flex-1 min-w-0">
<div class="flex items-center flex-wrap gap-2 mb-1">
<a href="{{ route('network.device', $event->device) }}"
class="font-semibold text-gray-900 dark:text-gray-100 hover:text-indigo-600">
{{ $event->device->display_name }}
</a>
@if($event->device->mac_address)
<span class="font-mono text-xs text-gray-400 bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded">
{{ $event->device->mac_address }}
</span>
@endif
</div>
<div class="flex items-center gap-3">
<span class="font-mono text-sm font-bold text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-2 py-0.5 rounded">
{{ $event->old_value }}
</span>
<span class="text-gray-400"></span>
<span class="font-mono text-sm font-bold text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-2 py-0.5 rounded">
{{ $event->new_value }}
</span>
<span class="text-xs text-gray-400">{{ $event->created_at->format('d.m.Y H:i') }}</span>
</div>
</div>
{{-- Bestätigen mit Notiz --}}
<form method="POST" action="{{ route('network.document', $event) }}"
class="flex gap-2 items-center shrink-0">
@csrf
<input type="text" name="description"
placeholder="Notiz zur Änderung (optional)"
class="text-xs border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded px-2 py-1.5 w-56 focus:ring-indigo-500 focus:border-indigo-500" />
<button type="submit"
class="shrink-0 text-xs px-3 py-1.5 rounded bg-green-600 text-white hover:bg-green-700 transition font-medium">
Quittieren
</button>
</form>
</div>
{{-- IP-Verlauf (sofern vorhanden) --}}
@php
$ipCount = $event->device->hosts()
->selectRaw('DISTINCT ip_address')
->count();
@endphp
@if($ipCount > 1)
<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">
📋 Dieses Gerät hatte {{ $ipCount }} verschiedene IP-Adressen
<a href="{{ route('network.device', $event->device) }}" class="underline hover:no-underline">IP-Verlauf anzeigen </a>
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
{{-- Globale Suche --}} {{-- Globale Suche --}}
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-5"> <div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-5">
<form method="GET" action="{{ route('network.search') }}" class="flex gap-3"> <form method="GET" action="{{ route('network.search') }}" class="flex gap-3">
@@ -80,9 +153,7 @@
</thead> </thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700"> <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($segments as $segment) @foreach($segments as $segment)
@php @php $lastScan = $latestScans->get($segment->id)?->first(); @endphp
$lastScan = $latestScans->get($segment->id)?->first();
@endphp
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition"> <tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="inline-block w-2 h-2 rounded-full {{ $segment->active ? 'bg-green-500' : 'bg-gray-300' }}"></span> <span class="inline-block w-2 h-2 rounded-full {{ $segment->active ? 'bg-green-500' : 'bg-gray-300' }}"></span>
@@ -117,7 +188,7 @@
@endif @endif
</div> </div>
{{-- Undokumentierte Ereignisse --}} {{-- Sonstige undokumentierte Ereignisse --}}
@if($recentEvents->count() > 0) @if($recentEvents->count() > 0)
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden"> <div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700"> <div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
@@ -128,14 +199,14 @@
</div> </div>
<div class="divide-y divide-gray-100 dark:divide-gray-700"> <div class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($recentEvents as $event) @foreach($recentEvents as $event)
<div class="flex items-center justify-between px-5 py-3"> <div class="flex items-center justify-between px-5 py-3 gap-4">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3 flex-1 min-w-0">
<span class="w-2 h-2 rounded-full flex-shrink-0 <span class="w-2 h-2 rounded-full flex-shrink-0
{{ $event->event_color === 'green' ? 'bg-green-500' : {{ $event->event_color === 'green' ? 'bg-green-500' :
($event->event_color === 'blue' ? 'bg-blue-500' : ($event->event_color === 'blue' ? 'bg-blue-500' :
($event->event_color === 'red' ? 'bg-red-500' : 'bg-yellow-500')) }}"> ($event->event_color === 'red' ? 'bg-red-500' : 'bg-yellow-500')) }}">
</span> </span>
<div> <div class="min-w-0">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $event->event_label }}</span> <span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $event->event_label }}</span>
<span class="text-xs text-gray-400 ml-2"> <span class="text-xs text-gray-400 ml-2">
<a href="{{ route('network.device', $event->device) }}" class="hover:text-indigo-600"> <a href="{{ route('network.device', $event->device) }}" class="hover:text-indigo-600">
@@ -143,10 +214,23 @@
</a> </a>
· {{ $event->created_at->format('d.m.Y H:i') }} · {{ $event->created_at->format('d.m.Y H:i') }}
</span> </span>
@if($event->old_value && $event->new_value)
<span class="ml-2 text-xs text-gray-400">
<span class="font-mono">{{ $event->old_value }}</span> <span class="font-mono">{{ $event->new_value }}</span>
</span>
@endif
</div> </div>
</div> </div>
<a href="{{ route('network.device', $event->device) }}" <form method="POST" action="{{ route('network.document', $event) }}"
class="text-xs text-indigo-600 hover:underline">Detail </a> class="flex gap-2 items-center shrink-0">
@csrf
<input type="text" name="description" placeholder="Notiz..."
class="text-xs border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded px-2 py-1 w-40 focus:ring-indigo-500 focus:border-indigo-500" />
<button type="submit"
class="text-xs px-3 py-1 rounded border border-green-300 text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 transition whitespace-nowrap">
Quittieren
</button>
</form>
</div> </div>
@endforeach @endforeach
</div> </div>
+28 -3
View File
@@ -101,10 +101,33 @@
{{-- IP-Verlauf --}} {{-- IP-Verlauf --}}
@if($ipHistory->count() > 0) @if($ipHistory->count() > 0)
@php
$distinctIps = $ipHistory->pluck('ip_address')->unique()->values();
@endphp
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden"> <div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700"> <div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-gray-100">IP-Verlauf</h3> <h3 class="font-semibold text-gray-900 dark:text-gray-100">IP-Verlauf</h3>
@if($distinctIps->count() > 1)
<span class="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">
⚠️ {{ $distinctIps->count() }} verschiedene IP-Adressen
</span>
@endif
</div> </div>
{{-- Distinct IPs auf einen Blick --}}
@if($distinctIps->count() > 1)
<div class="px-5 py-3 bg-amber-50 dark:bg-amber-900/10 border-b border-amber-100 dark:border-amber-800">
<p class="text-xs text-amber-700 dark:text-amber-400 font-medium mb-1">Bekannte IP-Adressen dieses Geräts:</p>
<div class="flex flex-wrap gap-2">
@foreach($distinctIps as $dip)
<span class="font-mono text-xs px-2 py-1 rounded {{ $dip === $device->current_ip ? 'bg-green-100 text-green-800 font-bold' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' }}">
{{ $dip }}{{ $dip === $device->current_ip ? ' ← aktuell' : '' }}
</span>
@endforeach
</div>
</div>
@endif
<table class="min-w-full divide-y divide-gray-100 dark:divide-gray-700 text-sm"> <table class="min-w-full divide-y divide-gray-100 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-700"> <thead class="bg-gray-50 dark:bg-gray-700">
<tr> <tr>
@@ -116,9 +139,11 @@
</thead> </thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700"> <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($ipHistory as $h) @foreach($ipHistory as $h)
<tr> <tr class="{{ $h->ip_address !== $device->current_ip ? 'opacity-60' : '' }}">
<td class="px-4 py-2 text-gray-500 dark:text-gray-400">{{ $h->created_at->format('d.m.Y H:i') }}</td> <td class="px-4 py-2 text-gray-500 dark:text-gray-400">{{ $h->created_at->format('d.m.Y H:i') }}</td>
<td class="px-4 py-2 font-mono text-gray-900 dark:text-gray-100">{{ $h->ip_address }}</td> <td class="px-4 py-2 font-mono font-medium {{ $h->ip_address === $device->current_ip ? 'text-green-700 dark:text-green-400' : 'text-gray-600 dark:text-gray-400' }}">
{{ $h->ip_address }}
</td>
<td class="px-4 py-2"> <td class="px-4 py-2">
<span class="text-xs {{ $h->status === 'online' ? 'text-green-600' : 'text-gray-400' }}">{{ $h->status }}</span> <span class="text-xs {{ $h->status === 'online' ? 'text-green-600' : 'text-gray-400' }}">{{ $h->status }}</span>
</td> </td>
+175
View File
@@ -0,0 +1,175 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Chronologischer IP-Verlauf</h2>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ $entries->total() }} Einträge</span>
</div>
</x-slot>
<div class="py-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-4">
{{-- Filter --}}
<form method="GET" action="{{ route('network.history') }}"
class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">IP-Adresse</label>
<input type="text" name="ip" value="{{ request('ip') }}"
placeholder="z.B. 192.168.86"
class="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" />
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">MAC-Adresse</label>
<input type="text" name="mac" value="{{ request('mac') }}"
placeholder="z.B. 00:50:56"
class="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" />
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Hostname / Bezeichnung</label>
<input type="text" name="hostname" value="{{ request('hostname') }}"
placeholder="z.B. frigo-nas"
class="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" />
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Segment</label>
<select name="segment"
class="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="">Alle Segmente</option>
@foreach($segments as $seg)
<option value="{{ $seg->id }}" {{ request('segment') == $seg->id ? 'selected' : '' }}>
{{ $seg->name }}
</option>
@endforeach
</select>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Status</label>
<select name="status"
class="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="">Alle</option>
<option value="online" {{ request('status') === 'online' ? 'selected' : '' }}>Online</option>
<option value="offline" {{ request('status') === 'offline' ? 'selected' : '' }}>Offline</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Von Datum</label>
<input type="date" name="from" value="{{ request('from') }}"
class="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" />
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Bis Datum</label>
<input type="date" name="to" value="{{ request('to') }}"
class="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" />
</div>
<div class="flex items-end gap-2">
<button type="submit" style="background-color: var(--color-primary)"
class="flex-1 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
Filtern
</button>
@if(request()->hasAny(['ip','mac','hostname','segment','status','from','to']))
<a href="{{ route('network.history') }}"
class="py-2 px-3 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">
</a>
@endif
</div>
</div>
</form>
{{-- Tabelle --}}
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
<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>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Datum / Zeit</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">IP-Adresse</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">MAC-Adresse</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hostname / Bezeichnung</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hersteller</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Segment</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Ping</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@forelse($entries as $entry)
<tr class="{{ $entry->status === 'online' ? '' : 'opacity-60' }} hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<td class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
{{ \Carbon\Carbon::parse($entry->scan_time)->format('d.m.Y H:i') }}
@if($entry->scanner)
<br><span class="text-gray-400">{{ $entry->scanner }}</span>
@endif
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center gap-1">
<span class="w-2 h-2 rounded-full {{ $entry->status === 'online' ? 'bg-green-500' : 'bg-gray-300' }}"></span>
<span class="text-xs {{ $entry->status === 'online' ? 'text-green-700 dark:text-green-400' : 'text-gray-400' }}">
{{ $entry->status }}
</span>
</span>
</td>
<td class="px-3 py-2 font-mono font-medium text-gray-900 dark:text-gray-100">
@if($entry->device_id)
<a href="{{ route('network.device', $entry->device_id) }}"
class="text-indigo-600 hover:underline">
{{ $entry->ip_address }}
</a>
@else
{{ $entry->ip_address }}
@endif
</td>
<td class="px-3 py-2 font-mono text-xs text-gray-600 dark:text-gray-400">
@if($entry->mac_address)
<span title="{{ $entry->mac_address }}">{{ $entry->mac_address }}</span>
@else
<span class="text-gray-300"></span>
@endif
</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">
@if($entry->device_label)
<span class="font-medium">{{ $entry->device_label }}</span>
@if($entry->hostname)
<span class="text-xs text-gray-400 ml-1">({{ $entry->hostname }})</span>
@endif
@else
{{ $entry->hostname ?? '—' }}
@endif
</td>
<td class="px-3 py-2 text-xs text-gray-600 dark:text-gray-400">{{ $entry->mac_vendor ?? '—' }}</td>
<td class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
{{ $entry->segment_name ?? '—' }}
</td>
<td class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
{{ $entry->ping_ms !== null ? $entry->ping_ms . ' ms' : '—' }}
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-4 py-10 text-center text-gray-500">
Keine Einträge gefunden.
</td>
</tr>
@endforelse
</tbody>
</table>
@if($entries->hasPages())
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
{{ $entries->links() }}
</div>
@endif
</div>
</div>
</div>
{{-- Auto-Refresh alle 60 Sekunden wenn kein Filter aktiv --}}
@unless(request()->hasAny(['ip','mac','hostname','segment','status','from','to']))
<script>
setTimeout(function() { window.location.reload(); }, 60000);
</script>
@endunless
</x-app-layout>
+94 -1
View File
@@ -1,10 +1,26 @@
<x-app-layout> <x-app-layout>
<x-slot name="header"> <x-slot name="header">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<a href="{{ route('network.index') }}" class="text-gray-500 hover:text-gray-700">Netzwerk</a> @if($scan->segment)
<a href="{{ route('network.segments.show', $scan->segment) }}" class="text-gray-500 hover:text-gray-700">{{ $scan->segment->name }}</a>
<span class="text-gray-400">/</span> <span class="text-gray-400">/</span>
@endif
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Scan {{ $scan->created_at->format('d.m.Y H:i') }}</h2> <h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Scan {{ $scan->created_at->format('d.m.Y H:i') }}</h2>
</div> </div>
@if($scan->segment)
<div class="flex gap-2">
<a href="{{ route('network.segments.export.xlsx', $scan->segment) }}"
class="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">
📊 Excel
</a>
<a href="{{ route('network.segments.export.pdf', $scan->segment) }}"
class="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">
📄 PDF
</a>
</div>
@endif
</div>
</x-slot> </x-slot>
<div class="py-8"> <div class="py-8">
@@ -32,6 +48,7 @@
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hersteller</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hersteller</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Ping</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Ping</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Ports</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Ports</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Bemerkung</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700"> <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@@ -54,6 +71,25 @@
<td class="px-4 py-2 text-xs text-gray-600 dark:text-gray-400">{{ $host->mac_vendor ?? '—' }}</td> <td class="px-4 py-2 text-xs text-gray-600 dark:text-gray-400">{{ $host->mac_vendor ?? '—' }}</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-gray-400">{{ $host->ping_ms ? $host->ping_ms . ' ms' : '—' }}</td> <td class="px-4 py-2 text-xs text-gray-600 dark:text-gray-400">{{ $host->ping_ms ? $host->ping_ms . ' ms' : '—' }}</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 max-w-xs truncate">{{ $host->ports ?? '—' }}</td> <td class="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 max-w-xs truncate">{{ $host->ports ?? '—' }}</td>
<td class="px-4 py-2 min-w-48">
@if($scan->segment_id)
<div class="note-cell" data-ip="{{ $host->ip_address }}">
<span class="note-display text-xs text-gray-600 dark:text-gray-400 cursor-pointer hover:text-indigo-600 italic"
title="Klicken zum Bearbeiten">
{{ $notes[$host->ip_address] ?? '+ Bemerkung' }}
</span>
<div class="note-form hidden flex gap-1 items-center">
<input type="text" maxlength="500"
class="note-input flex-1 text-xs border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded px-2 py-1 focus:ring-indigo-500 focus:border-indigo-500"
value="{{ $notes[$host->ip_address] ?? '' }}" />
<button class="note-save text-xs px-2 py-1 rounded text-white" style="background-color: var(--color-primary)"></button>
<button class="note-cancel text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400"></button>
</div>
</div>
@else
<span class="text-xs text-gray-300"></span>
@endif
</td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>
@@ -61,4 +97,61 @@
</div> </div>
</div> </div>
</div> </div>
@if($scan->segment_id)
<script>
(function() {
const saveUrl = '{{ route('network.segments.ip-notes', $scan->segment_id) }}';
const csrfToken = '{{ csrf_token() }}';
document.querySelectorAll('.note-cell').forEach(function(cell) {
const ip = cell.dataset.ip;
const display = cell.querySelector('.note-display');
const form = cell.querySelector('.note-form');
const input = cell.querySelector('.note-input');
const save = cell.querySelector('.note-save');
const cancel = cell.querySelector('.note-cancel');
display.addEventListener('click', function() {
display.classList.add('hidden');
form.classList.remove('hidden');
input.focus();
});
cancel.addEventListener('click', function() {
form.classList.add('hidden');
display.classList.remove('hidden');
});
save.addEventListener('click', function() {
fetch(saveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json',
},
body: JSON.stringify({ ip_address: ip, note: input.value }),
})
.then(r => r.json())
.then(function(data) {
if (data.ok) {
display.textContent = input.value || '+ Bemerkung';
form.classList.add('hidden');
display.classList.remove('hidden');
}
})
.catch(function() {
alert('Fehler beim Speichern.');
});
});
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') save.click();
if (e.key === 'Escape') cancel.click();
});
});
})();
</script>
@endif
</x-app-layout> </x-app-layout>
+69 -10
View File
@@ -26,11 +26,17 @@
@if($q && strlen($q) < 2) @if($q && strlen($q) < 2)
<p class="text-sm text-gray-500">Mindestens 2 Zeichen eingeben.</p> <p class="text-sm text-gray-500">Mindestens 2 Zeichen eingeben.</p>
@elseif($q) @elseif($q)
@php $totalFound = $devices->total() + $hostResults->count(); @endphp
<p class="text-sm text-gray-500 dark:text-gray-400"> <p class="text-sm text-gray-500 dark:text-gray-400">
{{ $devices->total() }} Ergebnis(se) für {{ $q }}" über alle Segmente {{ $totalFound }} Ergebnis(se) für {{ $q }}" über alle Segmente
</p> </p>
{{-- Ergebnisse aus network_devices (getracked) --}}
@if($devices->count() > 0)
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden"> <div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Geräte (bekannt)</span>
</div>
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm"> <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"> <thead class="bg-gray-50 dark:bg-gray-700">
<tr> <tr>
@@ -44,20 +50,23 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700"> <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@forelse($devices as $device) @foreach($devices as $device)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition"> <tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="w-2 h-2 rounded-full inline-block <span class="w-2 h-2 rounded-full inline-block
{{ $device->status === 'online' ? 'bg-green-500' : 'bg-red-400' }}"></span> {{ $device->status === 'online' ? 'bg-green-500' : 'bg-red-400' }}"></span>
</td> </td>
<td class="px-4 py-3 font-mono text-gray-900 dark:text-gray-100 font-medium">{{ $device->current_ip }}</td> <td class="px-4 py-3 font-mono text-gray-900 dark:text-gray-100 font-medium">{{ $device->current_ip }}</td>
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{{ $device->mac_address }}</td> <td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{{ $device->mac_address ?? '—' }}</td>
<td class="px-4 py-3 text-gray-900 dark:text-gray-100"> <td class="px-4 py-3 text-gray-900 dark:text-gray-100">
@if($device->label) @if($device->label)
<span class="font-medium">{{ $device->label }}</span> <span class="font-medium">{{ $device->label }}</span>
<span class="text-gray-400 text-xs ml-1">({{ $device->hostname }})</span> <span class="text-gray-400 text-xs ml-1">({{ $device->hostname }})</span>
@else @else
{{ $device->hostname ?? '—' }} {{ $device->hostname ?? $device->netbios_name ?? '—' }}
@endif
@if($device->netbios_name && $device->netbios_name !== $device->hostname)
<span class="block text-xs text-gray-400">NetBIOS: {{ $device->netbios_name }}</span>
@endif @endif
</td> </td>
<td class="px-4 py-3 text-xs text-gray-600 dark:text-gray-400">{{ $device->mac_vendor ?? '—' }}</td> <td class="px-4 py-3 text-xs text-gray-600 dark:text-gray-400">{{ $device->mac_vendor ?? '—' }}</td>
@@ -69,14 +78,9 @@
class="text-xs text-indigo-600 hover:underline">Detail </a> class="text-xs text-indigo-600 hover:underline">Detail </a>
</td> </td>
</tr> </tr>
@empty @endforeach
<tr>
<td colspan="7" class="px-4 py-10 text-center text-gray-500">Keine Geräte gefunden.</td>
</tr>
@endforelse
</tbody> </tbody>
</table> </table>
@if($devices->hasPages()) @if($devices->hasPages())
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700"> <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
{{ $devices->links() }} {{ $devices->links() }}
@@ -85,6 +89,61 @@
</div> </div>
@endif @endif
{{-- Ergebnisse aus network_hosts (Scan-Treffer ohne Device-Eintrag) --}}
@if($hostResults->count() > 0)
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
<div class="px-4 py-2 bg-blue-50 dark:bg-blue-900/20 border-b border-blue-100 dark:border-blue-800 flex items-center justify-between">
<span class="text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wider">
In Scan-Verlauf gefunden
</span>
<span class="text-xs text-blue-400">Noch kein Geräteeintrag wird beim nächsten Scan angelegt</span>
</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>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">IP-Adresse</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">MAC-Adresse</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hostname</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hersteller</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Segment</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Letzter Scan</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($hostResults as $host)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<td class="px-4 py-3">
<span class="w-2 h-2 rounded-full inline-block
{{ $host->status === 'online' ? 'bg-green-500' : 'bg-gray-300' }}"></span>
</td>
<td class="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">{{ $host->ip_address }}</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500 dark:text-gray-400">{{ $host->mac_address ?? '—' }}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ $host->hostname ?? '—' }}</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{{ $host->mac_vendor ?? '—' }}</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{{ $host->segment_name ?? '—' }}</td>
<td class="px-4 py-3 text-xs text-gray-400">
{{ \Carbon\Carbon::parse($host->scan_time)->format('d.m.Y H:i') }}
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('network.scan', $host->scan_id) }}"
class="text-xs text-indigo-600 hover:underline">Scan </a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@if($totalFound === 0)
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg px-4 py-10 text-center text-gray-500">
Keine Ergebnisse für {{ $q }}" gefunden.
</div>
@endif
@endif
</div> </div>
</div> </div>
</x-app-layout> </x-app-layout>
@@ -22,9 +22,16 @@
<div> <div>
<x-input-label for="subnet" value="Subnetz *" /> <x-input-label for="subnet" value="Subnetz *" />
<x-text-input id="subnet" name="subnet" type="text" class="mt-1 block w-full font-mono" <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 /> 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" /> <x-input-error :messages="$errors->get('subnet')" class="mt-1" />
<div id="subnet-suggestions" class="mt-1 hidden"></div>
</div> </div>
<div> <div>
@@ -41,6 +48,29 @@
placeholder="Kurze Beschreibung dieses Segments...">{{ old('description') }}</textarea> placeholder="Kurze Beschreibung dieses Segments...">{{ old('description') }}</textarea>
</div> </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"> <div class="flex items-center gap-2">
<input type="checkbox" id="active" name="active" value="1" <input type="checkbox" id="active" name="active" value="1"
{{ old('active', '1') ? 'checked' : '' }} {{ old('active', '1') ? 'checked' : '' }}
@@ -62,4 +92,38 @@
</div> </div>
</div> </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> </x-app-layout>
@@ -22,9 +22,16 @@
<div> <div>
<x-input-label for="subnet" value="Subnetz *" /> <x-input-label for="subnet" value="Subnetz *" />
<x-text-input id="subnet" name="subnet" type="text" class="mt-1 block w-full font-mono" <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 /> 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" /> <x-input-error :messages="$errors->get('subnet')" class="mt-1" />
<div id="subnet-suggestions" class="mt-1 hidden"></div>
</div> </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> 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>
<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"> <div class="flex items-center gap-2">
<input type="checkbox" id="active" name="active" value="1" <input type="checkbox" id="active" name="active" value="1"
{{ old('active', $segment->active) ? 'checked' : '' }} {{ old('active', $segment->active) ? 'checked' : '' }}
@@ -61,4 +88,38 @@
</div> </div>
</div> </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> </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> <span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">{{ $segment->name }}</h2> <h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">{{ $segment->name }}</h2>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2 items-center">
<a href="{{ route('network.import') }}?segment={{ $segment->id }}" {{-- Auto-Refresh Indikator --}}
style="background-color: var(--color-primary)" <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"> class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
+ Scan importieren Jetzt scannen
</button>
</form>
@endif
<a href="{{ route('network.import') }}?segment={{ $segment->id }}"
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>
<a href="{{ route('network.segments.edit', $segment) }}" <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 Bearbeiten
</a> </a>
</div> </div>
@@ -39,6 +62,12 @@
{{ $segment->active ? 'Aktiv' : 'Inaktiv' }} {{ $segment->active ? 'Aktiv' : 'Inaktiv' }}
</p> </p>
</div> </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"> <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="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> <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"> <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> <h3 class="font-semibold text-gray-900 dark:text-gray-100">Scan-Historie</h3>
</div> </div>
<div data-scan-count="{{ $scans->total() }}"></div>
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm"> <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"> <thead class="bg-gray-50 dark:bg-gray-700">
<tr> <tr>
@@ -112,4 +142,33 @@
</div> </div>
</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> </x-app-layout>
+9
View File
@@ -1,8 +1,17 @@
<?php <?php
use App\Models\NetworkSegment;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
// Netzwerk-Scans automatisch ausführen
// Der Scheduler prüft jede Minute ob ein Segment fällig ist
Schedule::command('network:scan')
->everyMinute()
->withoutOverlapping()
->runInBackground();
+15 -1
View File
@@ -42,12 +42,26 @@ Route::prefix('network')
// Dashboard // Dashboard
Route::get('/', [NetworkController::class, 'dashboard'])->name('dashboard'); Route::get('/', [NetworkController::class, 'dashboard'])->name('dashboard');
// Subnetz-Erkennung (JSON)
Route::get('/detect-subnets', [NetworkController::class, 'detectSubnets'])->name('detect-subnets');
// Globale Suche // Globale Suche
Route::get('/search', [NetworkController::class, 'search'])->name('search'); Route::get('/search', [NetworkController::class, 'search'])->name('search');
// Segmente (CRUD) // Segmente (CRUD + Scan auslösen)
Route::resource('segments', NetworkSegmentController::class) Route::resource('segments', NetworkSegmentController::class)
->names('segments'); ->names('segments');
Route::post('segments/{segment}/scan', [NetworkSegmentController::class, 'triggerScan'])
->name('segments.scan');
Route::post('segments/{segment}/ip-notes', [NetworkSegmentController::class, 'saveIpNote'])
->name('segments.ip-notes');
Route::get('segments/{segment}/export/xlsx', [NetworkSegmentController::class, 'exportXlsx'])
->name('segments.export.xlsx');
Route::get('segments/{segment}/export/pdf', [NetworkSegmentController::class, 'exportPdf'])
->name('segments.export.pdf');
// Chronologischer IP-Verlauf
Route::get('/history', [NetworkController::class, 'history'])->name('history');
// Geräte // Geräte
Route::get('/devices', [NetworkController::class, 'devices'])->name('devices'); Route::get('/devices', [NetworkController::class, 'devices'])->name('devices');