4 Commits

45 changed files with 3791 additions and 79 deletions
+34
View File
@@ -0,0 +1,34 @@
# Git
.git
.gitignore
# Node
node_modules
# Laravel
vendor
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*
# Test & Dev
tests
.env
.env.*
!.env.example
# Docker selbst
docker-compose*.yml
Dockerfile
docker/
# IDE
.idea
.vscode
*.swp
# OS
.DS_Store
Thumbs.db
+18 -10
View File
@@ -1,8 +1,9 @@
APP_NAME=Laravel
APP_ENV=local
APP_NAME="Network-MGMT"
APP_ENV=production
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_DEBUG=false
APP_URL=http://localhost:8080
APP_PORT=8080
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -20,12 +21,13 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=network_mgmt
DB_USERNAME=network_mgmt
DB_PASSWORD=secret
DB_ROOT_PASSWORD=rootsecret
SESSION_DRIVER=database
SESSION_LIFETIME=120
@@ -63,3 +65,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# ─── Gitea Update-Checker ────────────────────────────────────────────────────
# URL des Gitea-Servers (ohne trailing slash)
GITEA_URL=http://192.168.x.x:3000
# Repository im Format owner/repo
GITEA_REPO=admin/Network-MGMT
+114 -1
View File
@@ -9,6 +9,114 @@ Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/).
---
## [0.10.0] - 2026-07-02
### Added
- Docker-Setup: `Dockerfile` (PHP 8.5 FPM + nginx + Node.js 20 + nmap + supervisor), `docker-compose.yml` (App + MariaDB 11), automatischer Startup via `entrypoint.sh` (wartet auf DB, migriert, baut Assets, startet Supervisor)
- `docker/nginx.conf`, `docker/supervisord.conf`: nginx als Reverse-Proxy zu PHP-FPM, Laravel Scheduler als Supervisor-Job
- `.dockerignore` für minimale Image-Größe
- `.env.example` auf Docker-Betrieb angepasst (DB_HOST=db, MariaDB-Variablen, APP_PORT)
- Software-Update-Funktion: Admin → Einstellungen → 🔄 Software-Update
- `config/version.php`: zentrale Versionsverwaltung, konfigurierbare Gitea-URL
- Artisan-Command `app:check-update`: prüft Gitea-API auf neuere Release-Tags, speichert Ergebnis 6 Stunden im Cache
- Artisan-Command `app:install-update`: führt `git fetch`, `git checkout <tag>`, `composer install`, `npm run build`, `php artisan migrate`, Cache-Rebuild durch — mit automatischem Wartungsmodus
- `UpdateController` (Admin): Update-Seite mit Versionsvergleich, Release-Notes, Install-Button, manueller Tag-Eingabe
- Navigation: „Update"-Badge im Einstellungen-Dropdown wenn neue Version verfügbar (liest aus Cache, kein Live-HTTP-Request)
- Update-Check alle 6 Stunden via Laravel Scheduler (`app:check-update`)
- Wartungsseite `errors/maintenance.blade.php` mit Auto-Reload
### Setup (einmalig)
```
GITEA_URL=http://<IP-des-Gitea-Servers>:3000
GITEA_REPO=admin/Network-MGMT
```
In `.env` eintragen, danach steht der Update-Check zur Verfügung.
### Docker-Deployment
```bash
git clone http://<gitea>:3000/admin/Network-MGMT.git
cd Network-MGMT
cp .env.example .env
# .env anpassen (Passwörter, APP_URL, GITEA_URL)
docker compose up -d --build
```
---
## [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
- Globale Suche: Zeigt auch Treffer direkt aus `network_hosts` (Scan-Verlauf) wenn noch kein Geräteeintrag vorhanden
- Artisan-Command `network:backfill-devices`: Erstellt `network_devices`-Einträge für alle bestehenden `network_hosts` ohne `device_id`
### Setup (einmalig)
```
php artisan migrate
php artisan network:backfill-devices
```
---
## [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
- Netzwerk-Segmentverwaltung: Subnetze mit Name, VLAN-ID und Aktiv/Inaktiv-Flag definieren
- Neue Tabelle `network_segments` als organisatorische Einheit für Scan-Durchläufe
- Dashboard-Ansicht „Netzwerk" mit Übersicht aller Segmente, KPI-Karten und offenen Ereignissen
- Globale Suche über alle Segmente nach IP, MAC, Hostname und Bezeichnung
- Navigation „Netzwerk" als Dropdown: Dashboard, Segmente, Alle Geräte, Suche, Import
- Import-Seite: Segment-Auswahl beim Upload eines Angry IP Scanner Exports
- Segment-Detailseite mit Scan-Historie
- `segment_id` FK in `network_scans` Tabelle
---
## [0.5.0] - 2026-06-29
### Added
@@ -81,7 +189,12 @@ 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.5.0...HEAD
[Unreleased]: http://localhost:3000/admin/Network-MGMT/compare/v0.10.0...HEAD
[0.10.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.9.0...v0.10.0
[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
[0.3.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.2.0...v0.3.0
+45
View File
@@ -0,0 +1,45 @@
FROM php:8.5-fpm-bullseye
# System-Abhängigkeiten
RUN apt-get update && apt-get install -y \
git \
curl \
nginx \
nmap \
supervisor \
libzip-dev \
libpng-dev \
libonig-dev \
libxml2-dev \
zip \
unzip \
default-mysql-client \
&& docker-php-ext-install pdo_mysql mbstring zip bcmath \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Node.js 20
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Arbeitsverzeichnis
WORKDIR /var/www/html
# Konfigurationsdateien
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Log-Verzeichnisse anlegen
RUN mkdir -p /var/log/supervisor
# Port freigeben
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]
@@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class AppCheckUpdateCommand extends Command
{
protected $signature = 'app:check-update {--json : Ausgabe als JSON}';
protected $description = 'Prüft ob ein Update auf Gitea verfügbar ist und speichert das Ergebnis im Cache';
public function handle(): int
{
$current = config('version.current');
$giteaUrl = rtrim(config('version.gitea_url'), '/');
$repo = config('version.gitea_repo');
if (empty($giteaUrl)) {
$this->warn('GITEA_URL ist nicht konfiguriert.');
Cache::put('app_update_available', null, now()->addHours(1));
return Command::SUCCESS;
}
try {
$response = Http::timeout(8)
->get("{$giteaUrl}/api/v1/repos/{$repo}/releases/latest");
if (!$response->successful()) {
$this->warn("Gitea nicht erreichbar (HTTP {$response->status()}).");
Cache::put('app_update_check_error', "HTTP {$response->status()}", now()->addHours(1));
return Command::SUCCESS;
}
$latest = $response->json('tag_name', '');
$available = !empty($latest) && version_compare(
ltrim($latest, 'v'),
ltrim($current, 'v'),
'>'
);
// Ergebnis 6 Stunden cachen
Cache::put('app_update_available', $available ? $latest : false, now()->addHours(6));
Cache::put('app_update_checked_at', now()->toDateTimeString(), now()->addHours(6));
Cache::forget('app_update_check_error');
if ($this->option('json')) {
$this->line(json_encode([
'current' => $current,
'latest' => $latest,
'available' => $available,
]));
} else {
if ($available) {
$this->info("✓ Update verfügbar: {$current}{$latest}");
} else {
$this->info("✓ Kein Update — aktuelle Version: {$current}");
}
}
} catch (\Exception $e) {
$this->error('Verbindungsfehler: ' . $e->getMessage());
Cache::put('app_update_check_error', $e->getMessage(), now()->addHours(1));
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
class AppInstallUpdateCommand extends Command
{
protected $signature = 'app:install-update {--tag= : Bestimmten Git-Tag installieren}';
protected $description = 'Installiert das neueste Update vom Gitea-Repository';
public function handle(): int
{
$projectPath = base_path();
$this->info('');
$this->info('╔══════════════════════════════════════╗');
$this->info('║ Network-MGMT Update ║');
$this->info('╚══════════════════════════════════════╝');
$this->info('');
// ── 1. Tags holen ──────────────────────────────────────────────────────
$this->info('▶ Git: Aktuelle Tags holen ...');
$this->exec("cd {$projectPath} && git fetch --tags 2>&1");
// ── 2. Ziel-Tag bestimmen ──────────────────────────────────────────────
if ($this->option('tag')) {
$targetTag = $this->option('tag');
} else {
$targetTag = trim(shell_exec(
"cd {$projectPath} && git describe --tags \$(git rev-list --tags --max-count=1) 2>/dev/null"
));
}
if (empty($targetTag)) {
$this->error('Kein Release-Tag im Repository gefunden.');
return Command::FAILURE;
}
$currentVersion = config('version.current');
$this->info(" Aktuell: {$currentVersion}");
$this->info(" Ziel: {$targetTag}");
$this->info('');
if ($targetTag === $currentVersion) {
$this->warn('Bereits auf der neuesten Version. Abbruch.');
return Command::SUCCESS;
}
// ── 3. Wartungsmodus ───────────────────────────────────────────────────
$this->info('▶ Wartungsmodus aktivieren ...');
Artisan::call('down', ['--render' => 'errors.maintenance']);
try {
// ── 4. Git Checkout ────────────────────────────────────────────────
$this->info("▶ Git: Checkout {$targetTag} ...");
$this->exec("cd {$projectPath} && git checkout {$targetTag} 2>&1");
// ── 5. Composer ────────────────────────────────────────────────────
$this->info('▶ Composer: Abhängigkeiten aktualisieren ...');
$this->exec("cd {$projectPath} && composer install --no-dev --optimize-autoloader --no-interaction 2>&1");
// ── 6. Assets ──────────────────────────────────────────────────────
if (file_exists("{$projectPath}/package.json")) {
$this->info('▶ Node: Assets neu bauen ...');
$this->exec("cd {$projectPath} && npm ci --silent 2>&1");
$this->exec("cd {$projectPath} && npm run build 2>&1");
}
// ── 7. Migrationen ─────────────────────────────────────────────────
$this->info('▶ Datenbank: Migrationen ausführen ...');
Artisan::call('migrate', ['--force' => true, '--no-interaction' => true]);
$this->line(Artisan::output());
// ── 8. Cache leeren und neu aufbauen ───────────────────────────────
$this->info('▶ Cache: Neu aufbauen ...');
Artisan::call('config:cache');
Artisan::call('route:cache');
Artisan::call('view:cache');
// ── 9. Update-Cache zurücksetzen ───────────────────────────────────
Cache::forget('app_update_available');
Cache::forget('app_update_checked_at');
$this->info('');
$this->info("✓ Update auf {$targetTag} erfolgreich abgeschlossen!");
} catch (\Throwable $e) {
$this->error('Update fehlgeschlagen: ' . $e->getMessage());
Artisan::call('up');
return Command::FAILURE;
} finally {
// ── 10. Wartungsmodus deaktivieren ─────────────────────────────────
$this->info('▶ Wartungsmodus deaktivieren ...');
Artisan::call('up');
}
return Command::SUCCESS;
}
private function exec(string $cmd): void
{
$output = shell_exec($cmd);
if ($output) {
foreach (explode("\n", trim($output)) as $line) {
if (trim($line)) {
$this->line(" {$line}");
}
}
}
}
}
@@ -0,0 +1,132 @@
<?php
namespace App\Console\Commands;
use App\Models\NetworkDevice;
use App\Models\NetworkHost;
use Illuminate\Console\Command;
class NetworkBackfillDevicesCommand extends Command
{
protected $signature = 'network:backfill-devices
{--dry-run : Nur anzeigen, was gemacht würde, ohne zu schreiben}';
protected $description = 'Erstellt NetworkDevice-Einträge für bestehende network_hosts ohne device_id';
public function handle(): int
{
$dryRun = $this->option('dry-run');
// Alle Hosts ohne device_id, neueste zuerst (für korrekte last_seen_at)
$hosts = NetworkHost::query()
->join('network_scans', 'network_scans.id', '=', 'network_hosts.scan_id')
->whereNull('network_hosts.device_id')
->select([
'network_hosts.*',
'network_scans.created_at as scan_time',
])
->orderByDesc('network_scans.created_at')
->get();
$this->info("Gefunden: {$hosts->count()} Hosts ohne device_id");
if ($hosts->isEmpty()) {
$this->info('Nichts zu tun.');
return Command::SUCCESS;
}
$created = 0;
$linked = 0;
foreach ($hosts as $host) {
$device = null;
// 1. Per MAC suchen/erstellen
if (!empty($host->mac_address)) {
$device = NetworkDevice::where('mac_address', $host->mac_address)->first();
if (!$device) {
if (!$dryRun) {
$device = NetworkDevice::create([
'mac_address' => $host->mac_address,
'current_ip' => $host->ip_address,
'hostname' => $host->hostname,
'mac_vendor' => $host->mac_vendor,
'status' => $host->status,
'first_seen_at' => $host->scan_time,
'last_seen_at' => $host->scan_time,
]);
$created++;
}
$this->line(" + Gerät (MAC) {$host->mac_address} / {$host->ip_address}");
}
}
// 2. Per Hostname suchen/erstellen (kein MAC)
elseif (!empty($host->hostname)) {
$device = NetworkDevice::whereNull('mac_address')
->where('hostname', $host->hostname)
->first();
if (!$device) {
if (!$dryRun) {
$device = NetworkDevice::create([
'mac_address' => null,
'current_ip' => $host->ip_address,
'hostname' => $host->hostname,
'mac_vendor' => null,
'status' => $host->status,
'first_seen_at' => $host->scan_time,
'last_seen_at' => $host->scan_time,
]);
$created++;
}
$this->line(" + Gerät (Hostname) {$host->hostname} / {$host->ip_address}");
}
}
// 3. Per IP suchen/erstellen (weder MAC noch Hostname)
else {
$device = NetworkDevice::whereNull('mac_address')
->where('current_ip', $host->ip_address)
->first();
if (!$device) {
if (!$dryRun) {
$device = NetworkDevice::create([
'mac_address' => null,
'current_ip' => $host->ip_address,
'hostname' => null,
'mac_vendor' => null,
'status' => $host->status,
'first_seen_at' => $host->scan_time,
'last_seen_at' => $host->scan_time,
]);
$created++;
}
$this->line(" + Gerät (IP) {$host->ip_address}");
}
}
// Host mit Gerät verknüpfen
if ($device && !$dryRun) {
$host->update(['device_id' => $device->id]);
// Älteste first_seen_at beibehalten
if ($host->scan_time < $device->first_seen_at) {
$device->update(['first_seen_at' => $host->scan_time]);
}
$linked++;
}
}
if ($dryRun) {
$this->warn('Dry-Run keine Änderungen geschrieben.');
} else {
$this->info("✓ Fertig: {$created} Geräte neu erstellt, {$linked} Hosts verknüpft.");
}
return Command::SUCCESS;
}
}
+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)));
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
class UpdateController extends Controller
{
public function index(): View
{
$currentVersion = config('version.current');
$giteaUrl = rtrim(config('version.gitea_url'), '/');
$giteaRepo = config('version.gitea_repo');
$latestVersion = null;
$updateAvailable = false;
$releaseNotes = null;
$error = null;
$checkedAt = Cache::get('app_update_checked_at');
if (empty($giteaUrl)) {
$error = 'GITEA_URL ist nicht in der .env konfiguriert. Bitte eintragen und Container neu starten.';
} else {
// Frisch prüfen (Cache überschreiben bei manuellem Aufruf)
try {
$response = Http::timeout(8)
->get("{$giteaUrl}/api/v1/repos/{$giteaRepo}/releases/latest");
if ($response->successful()) {
$latestVersion = $response->json('tag_name', '');
$releaseNotes = $response->json('body', '');
$updateAvailable = !empty($latestVersion) && version_compare(
ltrim($latestVersion, 'v'),
ltrim($currentVersion, 'v'),
'>'
);
Cache::put('app_update_available', $updateAvailable ? $latestVersion : false, now()->addHours(6));
Cache::put('app_update_checked_at', now()->toDateTimeString(), now()->addHours(6));
$checkedAt = now()->toDateTimeString();
} else {
$error = "Gitea nicht erreichbar (HTTP {$response->status()}).";
}
} catch (\Exception $e) {
$error = 'Verbindungsfehler: ' . $e->getMessage();
}
}
return view('admin.update', compact(
'currentVersion', 'latestVersion', 'updateAvailable',
'releaseNotes', 'error', 'checkedAt', 'giteaUrl', 'giteaRepo'
));
}
public function install(Request $request): RedirectResponse
{
$tag = $request->input('tag');
// Update-Command synchron ausführen und Ausgabe erfassen
Artisan::call('app:install-update', array_filter(['--tag' => $tag]));
$log = Artisan::output();
$success = !str_contains($log, 'fehlgeschlagen') && !str_contains($log, 'FAILURE');
return redirect()->route('admin.update.index')->with([
'update_result' => $success ? 'success' : 'error',
'update_log' => $log,
]);
}
}
+171 -14
View File
@@ -4,32 +4,177 @@ 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
{
// --- Übersicht ---
public function index(): View
// --- Subnetz-Erkennung aus vorhandenen IP-Daten ---
public function detectSubnets(): JsonResponse
{
$latestScan = NetworkScan::latest()->first();
$totalDevices = NetworkDevice::count();
$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();
// 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')
->where('documented', false)
->where('event_type', '!=', 'ip_changed')
->latest()
->take(10)
->take(15)
->get();
$scans = NetworkScan::latest()->take(10)->get();
return view('network.index', compact(
'latestScan', 'totalDevices', 'onlineDevices', 'recentEvents', 'scans'
// Letzten Scan pro Segment
$latestScans = NetworkScan::select('network_scans.*')
->orderByDesc('created_at')
->get()
->groupBy('segment_id');
return view('network.dashboard', compact(
'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}%")
->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', 'hostResults'));
}
// --- Alte index-Route umleiten ---
public function index(): \Illuminate\Http\RedirectResponse
{
return redirect()->route('network.dashboard');
}
// --- Alle Geräte ---
public function devices(Request $request): View
{
@@ -136,19 +281,25 @@ class NetworkController extends Controller
// --- Import ---
public function showImport(): View
{
return view('network.import');
$segments = NetworkSegment::where('active', true)->orderBy('name')->get();
return view('network.import', compact('segments'));
}
public function import(Request $request, NetworkScanImporter $importer): RedirectResponse
{
$request->validate([
'scan_file' => ['required', 'file', 'mimes:txt,csv', 'max:5120'],
'segment_id' => ['nullable', 'exists:network_segments,id'],
'scan_file' => ['required', 'file', 'mimes:txt,csv', 'max:10240'],
]);
$path = $request->file('scan_file')->store('imports', 'local');
$fullPath = storage_path('app/' . $path);
$path = $request->file('scan_file')->store('imports', 'local');
$fullPath = Storage::disk('local')->path($path);
$scan = $importer->importAngryIpScannerFile($fullPath, auth()->id());
$scan = $importer->importAngryIpScannerFile(
$fullPath,
auth()->id(),
$request->input('segment_id')
);
return redirect()->route('network.scan', $scan)
->with('success', "Import abgeschlossen: {$scan->online_hosts} online, {$scan->new_devices} neue Geräte.");
@@ -161,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'));
}
}
@@ -0,0 +1,179 @@
<?php
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
{
public function index(): View
{
$segments = NetworkSegment::withCount('scans')
->orderBy('name')
->get();
return view('network.segments.index', compact('segments'));
}
public function create(): View
{
return view('network.segments.create');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'subnet' => ['required', 'string', 'max:50'],
'vlan_id' => ['nullable', 'integer', 'min:1', 'max:4094'],
'active' => ['boolean'],
'description' => ['nullable', 'string', 'max:500'],
]);
$validated['active'] = $request->boolean('active', true);
$validated['created_by'] = auth()->id();
NetworkSegment::create($validated);
return redirect()->route('network.segments.index')
->with('success', "Segment \"{$validated['name']}\" angelegt.");
}
public function show(NetworkSegment $segment): View
{
$scans = $segment->scans()->latest()->paginate(20);
$latestScan = $segment->scans()->latest()->first();
return view('network.segments.show', compact('segment', 'scans', 'latestScan'));
}
public function edit(NetworkSegment $segment): View
{
return view('network.segments.edit', compact('segment'));
}
public function update(Request $request, NetworkSegment $segment): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'subnet' => ['required', 'string', 'max:50'],
'vlan_id' => ['nullable', 'integer', 'min:1', 'max:4094'],
'active' => ['boolean'],
'description' => ['nullable', 'string', 'max:500'],
]);
$validated['active'] = $request->boolean('active', true);
$segment->update($validated);
return redirect()->route('network.segments.index')
->with('success', "Segment \"{$segment->name}\" aktualisiert.");
}
public function destroy(NetworkSegment $segment): RedirectResponse
{
$name = $segment->name;
$segment->delete();
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();
}
}
+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);
}
}
+6 -1
View File
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class NetworkScan extends Model
{
protected $fillable = [
'subnet', 'source', 'scanner', 'total_hosts',
'segment_id', 'subnet', 'source', 'scanner', 'total_hosts',
'online_hosts', 'new_devices', 'changed_devices',
'notes', 'created_by',
];
@@ -27,4 +27,9 @@ class NetworkScan extends Model
{
return $this->belongsTo(User::class, 'created_by');
}
public function segment(): BelongsTo
{
return $this->belongsTo(NetworkSegment::class, 'segment_id');
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
class NetworkSegment extends Model
{
protected $fillable = [
'name', 'subnet', 'vlan_id', 'active', 'description',
'scan_interval_minutes', 'last_scanned_at', 'nmap_options', 'created_by',
];
protected $casts = [
'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');
}
public function scans(): HasMany
{
return $this->hasMany(NetworkScan::class, 'segment_id');
}
public function latestScan(): HasMany
{
return $this->hasMany(NetworkScan::class, 'segment_id')->latestOfMany();
}
/** Alle Hosts dieses Segments (über Scans) */
public function hosts(): HasManyThrough
{
return $this->hasManyThrough(NetworkHost::class, NetworkScan::class, 'segment_id', 'scan_id');
}
/** Anzahl online-Hosts im letzten Scan */
public function getOnlineCountAttribute(): int
{
return $this->scans()->latest()->first()?->online_hosts ?? 0;
}
/** Anzahl Geräte gesamt im letzten Scan */
public function getTotalCountAttribute(): int
{
return $this->scans()->latest()->first()?->total_hosts ?? 0;
}
/** Letzter Scan-Zeitpunkt */
public function getLastScannedAtAttribute(): ?string
{
return $this->scans()->latest()->first()?->created_at?->format('d.m.Y H:i');
}
}
@@ -3,6 +3,7 @@
namespace App\Providers;
use App\Services\SettingsService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
@@ -29,5 +30,13 @@ class SettingsServiceProvider extends ServiceProvider
'theme_mode' => 'light',
]);
}
// Update-Badge: aus Cache lesen (kein HTTP-Request auf jeder Seite)
if (!$this->app->runningInConsole()) {
$updateAvailableVersion = Cache::get('app_update_available', null);
View::share('navUpdateAvailable', $updateAvailableVersion ?: false);
} else {
View::share('navUpdateAvailable', false);
}
}
}
+86 -37
View File
@@ -8,6 +8,7 @@ use App\Models\NetworkHost;
use App\Models\NetworkScan;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class NetworkScanImporter
{
@@ -19,47 +20,84 @@ class NetworkScanImporter
/**
* Importiert eine Angry IP Scanner .txt Exportdatei.
*/
public function importAngryIpScannerFile(string $filePath, int $createdBy): NetworkScan
public function importAngryIpScannerFile(string $filePath, int $createdBy, ?int $segmentId = null): NetworkScan
{
$lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$scanner = '';
$subnet = '';
$headers = [];
$rows = [];
// Datei einlesen und UTF-8 BOM entfernen
$content = file_get_contents($filePath);
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content); // UTF-8 BOM
$content = str_replace("\r\n", "\n", $content); // Windows CRLF → LF
$content = str_replace("\r", "\n", $content); // altes Mac CR → LF
$lines = array_filter(explode("\n", $content), fn($l) => trim($l) !== '');
$lines = array_values($lines);
$scanner = '';
$subnet = '';
$headers = [];
$rows = [];
$splitPattern = null; // wird beim ersten Header-Treffer gesetzt
foreach ($lines as $line) {
if (str_starts_with($line, 'Erstellt von') || str_starts_with($line, 'Created by')) {
$scanner = trim(str_replace(['Erstellt von ', 'Created by '], '', $line));
$trimmed = trim($line);
if (str_starts_with($trimmed, 'Erstellt von') || str_starts_with($trimmed, 'Created by')) {
$scanner = trim(str_replace(['Erstellt von ', 'Created by '], '', $trimmed));
continue;
}
if (str_starts_with($line, 'Gescannt') || str_starts_with($line, 'Scanned')) {
// "Gescannt 192.168.86.0 - 192.168.86.255"
preg_match('/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/', $line, $m);
if (str_starts_with($trimmed, 'Gescannt') || str_starts_with($trimmed, 'Scanned')) {
preg_match('/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/', $trimmed, $m);
$subnet = $m[1] ?? '0.0.0.0';
continue;
}
if (str_starts_with($line, 'http') || str_starts_with($line, 'https')) {
continue; // URL-Zeile überspringen
}
// Header-Zeile (beginnt mit "IP")
if (str_starts_with($line, 'IP') && empty($headers)) {
$headers = preg_split('/\t+/', $line);
$headers = array_map('trim', $headers);
if (str_starts_with($trimmed, 'http')) {
continue;
}
// Datenzeile
if (!empty($headers) && str_starts_with(trim($line), '') && preg_match('/^\d{1,3}\./', trim($line))) {
$cols = preg_split('/\t+/', $line);
$row = [];
// Header-Zeile erkennen (beginnt mit "IP")
if (str_starts_with($trimmed, 'IP') && empty($headers)) {
// Trennzeichen auto-detektieren
if (substr_count($trimmed, "\t") >= 2) {
$splitPattern = '/\t/';
} elseif (substr_count($trimmed, ';') >= 2) {
$splitPattern = '/;/';
} elseif (substr_count($trimmed, ',') >= 2) {
$splitPattern = '/,/';
} else {
// Angry IP Scanner: mehrere normale Leerzeichen als Trennzeichen
// Non-Breaking Spaces (0xC2 0xA0) bleiben innerhalb von Werten erhalten
$splitPattern = '/ {2,}/';
}
$headers = array_map('trim', preg_split($splitPattern, $trimmed));
continue;
}
// Datenzeile: muss mit IP-Adresse beginnen
if (!empty($headers) && $splitPattern !== null
&& preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/', $trimmed)) {
$cols = array_map(
fn($v) => trim(str_replace("\xc2\xa0", ' ', $v)), // Non-Breaking Spaces → normale Spaces
preg_split($splitPattern, $line)
);
$row = [];
foreach ($headers as $i => $h) {
$row[$h] = trim($cols[$i] ?? '');
$row[$h] = $cols[$i] ?? '';
}
$rows[] = $row;
}
}
return DB::transaction(function () use ($rows, $scanner, $subnet, $createdBy) {
// DEBUG: Log parse results
Log::info('NetworkScanImporter: scanner=' . $scanner . ', subnet=' . $subnet);
Log::info('NetworkScanImporter: separator hex=' . bin2hex($separator));
Log::info('NetworkScanImporter: headers=' . json_encode($headers));
Log::info('NetworkScanImporter: rows found=' . count($rows));
if (!empty($rows)) {
Log::info('NetworkScanImporter: first row=' . json_encode($rows[0]));
}
return DB::transaction(function () use ($rows, $scanner, $subnet, $createdBy, $segmentId) {
$this->scan = NetworkScan::create([
'segment_id' => $segmentId,
'subnet' => $subnet,
'source' => 'import',
'scanner' => $scanner,
@@ -83,23 +121,23 @@ class NetworkScanImporter
private function processRow(array $row): void
{
$ip = $this->extractColumn($row, ['IP']);
$ping = $this->extractColumn($row, ['Ping']);
$host = $this->extractColumn($row, ['Hostname']);
$mac = $this->normalizeMAC($this->extractColumn($row, ['MAC Addresse', 'MAC Address']));
$vendor = $this->extractColumn($row, ['MAC Hersteller', 'MAC Vendor']);
$ttl = (int) $this->extractColumn($row, ['TTL']) ?: null;
$ports = $this->extractColumn($row, ['Ports']);
$netbios= $this->extractColumn($row, ['NetBIOS Info']);
$http = $this->extractColumn($row, ['HTTP Sender']);
$web = $this->extractColumn($row, ['Web Erkennung', 'Web Detection']);
$ip = trim($this->extractColumn($row, ['IP', 'IP-Adresse', 'IP Address', 'IP Adresse']));
$ping = trim($this->extractColumn($row, ['Ping', 'Ping (ms)']));
$host = $this->cleanValue($this->extractColumn($row, ['Hostname', 'Host Name', 'DNS-Hostname']));
$mac = $this->normalizeMAC($this->extractColumn($row, ['MAC Adresse', 'MAC Addresse', 'MAC Address', 'MAC-Adresse', 'MAC']));
$vendor = $this->cleanValue($this->extractColumn($row, ['MAC Hersteller', 'MAC Vendor', 'Hersteller', 'Vendor']));
$ttl = (int) $this->cleanValue($this->extractColumn($row, ['TTL'])) ?: null;
$ports = $this->cleanValue($this->extractColumn($row, ['Ports', 'Gefilterte Ports', 'Offene Ports']));
$netbios= $this->cleanValue($this->extractColumn($row, ['NetBIOS Info', 'NetBIOS-Info', 'NetBIOS']));
$http = $this->cleanValue($this->extractColumn($row, ['HTTP Sender', 'HTTP']));
$web = $this->cleanValue($this->extractColumn($row, ['Web Erkennung', 'Web Detection', 'Webserver']));
$pingMs = null;
$status = 'offline';
if (!empty($ping) && $ping !== 'n/a' && $ping !== '-') {
preg_match('/(\d+)/', $ping, $m);
$pingMs = isset($m[1]) ? (int)$m[1] : null;
// Online-Erkennung: Ping enthält eine Zahl gefolgt von "ms"
if (preg_match('/(\d+)\s*ms/i', $ping, $m)) {
$pingMs = (int) $m[1];
$status = 'online';
$this->onlineHosts++;
}
@@ -210,6 +248,17 @@ class NetworkScanImporter
return '';
}
/**
* Bereinigt Angry IP Scanner Platzhalterwerte wie [n/a], [n/s], [n/d] zu leerem String.
*/
private function cleanValue(string $value): string
{
if (preg_match('/^\[n\/[asd]\]$/i', trim($value))) {
return '';
}
return trim($value);
}
private function normalizeMAC(string $mac): string
{
// Verschiedene MAC-Formate normalisieren zu XX:XX:XX:XX:XX:XX
+1
View File
@@ -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": {
Generated
+94 -1
View File
@@ -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",
+21
View File
@@ -0,0 +1,21 @@
<?php
return [
/*
* Aktuelle App-Version wird bei jedem Update automatisch angepasst.
* Muss dem Git-Tag des letzten Releases entsprechen (z.B. "v0.9.0").
*/
'current' => 'v0.10.0',
/*
* Gitea-Basis-URL (ohne trailing slash), z.B. http://192.168.1.10:3000
* Wird aus der .env gelesen: GITEA_URL
*/
'gitea_url' => env('GITEA_URL', ''),
/*
* Gitea-Repository im Format "owner/repo"
* Wird aus der .env gelesen: GITEA_REPO
*/
'gitea_repo' => env('GITEA_REPO', 'admin/Network-MGMT'),
];
@@ -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::create('network_segments', function (Blueprint $table) {
$table->id();
$table->string('name'); // z.B. "Büro", "Server-VLAN"
$table->string('subnet'); // z.B. "192.168.86.0/24"
$table->unsignedSmallInteger('vlan_id')->nullable(); // z.B. 10, 20
$table->boolean('active')->default(true); // aktiv überwachen
$table->text('description')->nullable(); // optionale Beschreibung
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('network_segments');
}
};
@@ -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_scans', function (Blueprint $table) {
$table->foreignId('segment_id')
->nullable()
->after('id')
->constrained('network_segments')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('network_scans', function (Blueprint $table) {
$table->dropForeign(['segment_id']);
$table->dropColumn('segment_id');
});
}
};
@@ -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');
}
};
+167
View File
@@ -0,0 +1,167 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════════
# Network-MGMT — Deploy-Skript
#
# Ausführen auf deinem lokalen Rechner: bash deploy.sh
# Der Zielserver klont die App direkt von Gitea.
#
# Voraussetzung: SSH-Zugang zum Zielserver
# ═══════════════════════════════════════════════════════════════════
set -euo pipefail
G='\033[0;32m'; Y='\033[1;33m'; B='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
echo ""
echo -e "${B}${BOLD}╔═══════════════════════════════════════════╗${NC}"
echo -e "${B}${BOLD}║ Network-MGMT — Deployment ║${NC}"
echo -e "${B}${BOLD}╚═══════════════════════════════════════════╝${NC}"
echo ""
# ── Eingaben ───────────────────────────────────────────────────────
echo -e "${BOLD}Kunden-Server (Ziel)${NC}"
read -rp " IP-Adresse / Hostname : " SERVER_HOST
read -rp " SSH-Benutzer [root] : " SERVER_USER; SERVER_USER="${SERVER_USER:-root}"
read -rp " SSH-Port [22] : " SERVER_PORT; SERVER_PORT="${SERVER_PORT:-22}"
echo ""
echo -e "${BOLD}Gitea${NC}"
read -rp " Gitea-URL (dein Server) : " GITEA_URL; GITEA_URL="${GITEA_URL:-https://git.mms-systemservice.de}"
read -rp " Gitea-Benutzer : " GITEA_USER; GITEA_USER="${GITEA_USER:-gitea-mms}"
read -rsp " Gitea-Passwort : " GITEA_PASS; echo
echo ""
echo -e "${BOLD}App-Einstellungen${NC}"
read -rp " App-Port [8080] : " APP_PORT; APP_PORT="${APP_PORT:-8080}"
APP_URL="http://${SERVER_HOST}:${APP_PORT}"
echo -e " → Erreichbar unter: ${G}${APP_URL}${NC}"
echo ""
echo -e "${BOLD}Datenbank${NC}"
read -rsp " DB-Passwort : " DB_PASSWORD; echo
DB_ROOT_PASSWORD="${DB_PASSWORD}ROOT"
# ── Bestätigung ────────────────────────────────────────────────────
echo ""
echo -e "${Y}────────────────────────────────────────────────${NC}"
echo " Ziel: ${SERVER_USER}@${SERVER_HOST}:${SERVER_PORT}"
echo " Quelle: ${GITEA_URL}/${GITEA_USER}/Network-MGMT"
echo " App-URL: ${APP_URL}"
echo -e "${Y}────────────────────────────────────────────────${NC}"
echo ""
read -rp "Deployment starten? [j/N] " CONFIRM
[[ "${CONFIRM,,}" == "j" ]] || { echo "Abgebrochen."; exit 0; }
# ── Setup-Skript mit eingebetteten Werten erzeugen ─────────────────
REMOTE_SCRIPT="/tmp/nm_deploy_$$.sh"
{
printf '#!/bin/bash\nset -euo pipefail\n\n'
printf 'G='"'"'\033[0;32m'"'"'; Y='"'"'\033[1;33m'"'"'; BOLD='"'"'\033[1m'"'"'; NC='"'"'\033[0m'"'"'\n\n'
printf 'GITEA_URL=%q\n' "$GITEA_URL"
printf 'GITEA_USER=%q\n' "$GITEA_USER"
printf 'GITEA_PASS=%q\n' "$GITEA_PASS"
printf 'APP_URL=%q\n' "$APP_URL"
printf 'APP_PORT=%q\n' "$APP_PORT"
printf 'DB_PASSWORD=%q\n' "$DB_PASSWORD"
printf 'DB_ROOT_PASSWORD=%q\n' "$DB_ROOT_PASSWORD"
printf 'GITEA_FULL_URL=%q\n' "${GITEA_URL}"
cat << 'SETUP'
DEPLOY_PATH="/opt/network-mgmt"
REPO_URL="${GITEA_URL/https:\/\//https://${GITEA_USER}:${GITEA_PASS}@}"
REPO_URL="${REPO_URL}/gitea-mms/Network-MGMT.git"
# Credentials in URL einbauen
CLONE_URL=$(echo "$GITEA_URL" | sed "s|https://|https://${GITEA_USER}:${GITEA_PASS}@|")
CLONE_URL="${CLONE_URL}/${GITEA_USER}/Network-MGMT.git"
echo ""
echo -e "${Y}[1/4] Docker prüfen / installieren ...${NC}"
if ! command -v docker &>/dev/null; then
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
fi
echo -e "${G} ✓ Docker: $(docker --version | awk '{print $3}' | tr -d ',')${NC}"
if ! docker compose version &>/dev/null 2>&1; then
ARCH="$(uname -m)"
mkdir -p "${HOME}/.docker/cli-plugins"
curl -fsSL \
"https://github.com/docker/compose/releases/latest/download/docker-compose-linux-${ARCH}" \
-o "${HOME}/.docker/cli-plugins/docker-compose"
chmod +x "${HOME}/.docker/cli-plugins/docker-compose"
fi
echo -e "${G} ✓ Docker Compose: $(docker compose version --short 2>/dev/null)${NC}"
echo ""
echo -e "${Y}[2/4] Repository von Gitea klonen / aktualisieren ...${NC}"
if [ -d "${DEPLOY_PATH}/.git" ]; then
cd "$DEPLOY_PATH"
git pull
echo -e "${G} ✓ Repository aktualisiert${NC}"
else
git clone "$CLONE_URL" "$DEPLOY_PATH"
echo -e "${G} ✓ Repository geklont${NC}"
fi
echo ""
echo -e "${Y}[3/4] Konfiguration ...${NC}"
cd "$DEPLOY_PATH"
mkdir -p storage/logs storage/framework/{cache,sessions,views} bootstrap/cache
chmod -R 775 storage bootstrap/cache
if [ ! -f ".env" ]; then
cp .env.example .env
sed -i "s|APP_URL=.*|APP_URL=${APP_URL}|" .env
sed -i "s|APP_PORT=.*|APP_PORT=${APP_PORT}|" .env
sed -i "s|DB_PASSWORD=secret|DB_PASSWORD=${DB_PASSWORD}|" .env
sed -i "s|DB_ROOT_PASSWORD=rootsecret|DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}|" .env
sed -i "s|GITEA_URL=.*|GITEA_URL=${GITEA_FULL_URL}|" .env
sed -i "s|GITEA_REPO=.*|GITEA_REPO=${GITEA_USER}/Network-MGMT|" .env
echo -e "${G} ✓ .env erstellt${NC}"
else
echo -e "${G} ✓ .env bereits vorhanden (nicht überschrieben)${NC}"
fi
echo ""
echo -e "${Y}[4/4] Container bauen und starten ...${NC}"
echo " (Erster Start: 35 Minuten)"
docker compose up -d --build
echo " Warte auf Start ..."
sleep 8
for i in $(seq 1 20); do
docker compose ps | grep -qE "healthy|Up" && break || sleep 5
done
echo ""
docker compose ps
echo ""
echo -e "${G}${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${G}${BOLD}║ ✓ Network-MGMT erfolgreich installiert! ║${NC}"
echo -e "${G}${BOLD}║ ║${NC}"
printf "${G}${BOLD}║ Browser: %-40s║${NC}\n" "${APP_URL}"
printf "${G}${BOLD}║ Pfad: %-40s║${NC}\n" "${DEPLOY_PATH}"
echo -e "${G}${BOLD}║ ║${NC}"
echo -e "${G}${BOLD}║ Logs: docker compose logs -f ║${NC}"
echo -e "${G}${BOLD}║ Stop: docker compose down ║${NC}"
echo -e "${G}${BOLD}╚══════════════════════════════════════════════════╝${NC}"
echo ""
SETUP
} > "$REMOTE_SCRIPT"
chmod +x "$REMOTE_SCRIPT"
# ── Script auf Server übertragen und ausführen ─────────────────────
echo ""
echo -e "${Y}▶ Verbinde mit ${SERVER_USER}@${SERVER_HOST} ...${NC}"
scp -q -P "$SERVER_PORT" "$REMOTE_SCRIPT" "${SERVER_USER}@${SERVER_HOST}:/tmp/nm_deploy.sh"
rm -f "$REMOTE_SCRIPT"
echo -e "${Y}▶ Setup läuft (Ausgabe vom Server):${NC}"
echo -e "${Y}────────────────────────────────────────────────${NC}"
ssh -p "$SERVER_PORT" "${SERVER_USER}@${SERVER_HOST}" \
"bash /tmp/nm_deploy.sh; rm -f /tmp/nm_deploy.sh"
echo -e "${Y}────────────────────────────────────────────────${NC}"
echo ""
echo -e "${G}${BOLD}✓ Fertig! → ${APP_URL}${NC}"
echo ""
+48
View File
@@ -0,0 +1,48 @@
services:
# ─── Laravel-App (PHP-FPM + nginx) ──────────────────────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
container_name: network-mgmt-app
restart: unless-stopped
volumes:
- .:/var/www/html # App-Code (für git-pull-Updates)
- /var/www/html/node_modules # node_modules im Container halten
ports:
- "${APP_PORT:-8080}:80"
env_file:
- .env
depends_on:
db:
condition: service_healthy
networks:
- network-mgmt
# ─── MariaDB ────────────────────────────────────────────────────────────────
db:
image: mariadb:11
container_name: network-mgmt-db
restart: unless-stopped
environment:
MARIADB_DATABASE: "${DB_DATABASE:-network_mgmt}"
MARIADB_USER: "${DB_USERNAME:-network_mgmt}"
MARIADB_PASSWORD: "${DB_PASSWORD:-secret}"
MARIADB_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-rootsecret}"
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 15
networks:
- network-mgmt
networks:
network-mgmt:
driver: bridge
volumes:
db_data:
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
set -e
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ Network-MGMT Startup ║"
echo "╚══════════════════════════════════════╝"
echo ""
cd /var/www/html
# ─── .env prüfen ───────────────────────────────────────────────────────────────
if [ ! -f ".env" ]; then
echo "⚠ .env fehlt — kopiere .env.example ..."
cp .env.example .env
fi
# ─── Auf Datenbank warten ──────────────────────────────────────────────────────
echo "▶ Warte auf MariaDB ..."
until php -r "
\$dsn = 'mysql:host=' . getenv('DB_HOST') . ';port=' . (getenv('DB_PORT') ?: 3306) . ';dbname=' . getenv('DB_DATABASE');
try {
new PDO(\$dsn, getenv('DB_USERNAME'), getenv('DB_PASSWORD'));
exit(0);
} catch (Exception \$e) {
exit(1);
}
" 2>/dev/null; do
echo " ... noch nicht bereit, warte 3s"
sleep 3
done
echo " ✓ Datenbank erreichbar"
# ─── Composer ──────────────────────────────────────────────────────────────────
if [ ! -d "vendor" ] || [ ! -f "vendor/autoload.php" ]; then
echo "▶ Composer: Abhängigkeiten installieren ..."
composer install --no-dev --optimize-autoloader --no-interaction --quiet
echo " ✓ fertig"
fi
# ─── Assets bauen ──────────────────────────────────────────────────────────────
if [ ! -d "public/build" ] && [ -f "package.json" ]; then
echo "▶ Node: Assets bauen ..."
npm ci --silent 2>/dev/null
npm run build 2>/dev/null
echo " ✓ fertig"
fi
# ─── App-Key ───────────────────────────────────────────────────────────────────
APP_KEY_VAL=$(grep "^APP_KEY=" .env | cut -d= -f2)
if [ -z "$APP_KEY_VAL" ] || [ "$APP_KEY_VAL" = "" ]; then
echo "▶ Generiere APP_KEY ..."
php artisan key:generate --no-interaction --force
fi
# ─── Storage-Link ──────────────────────────────────────────────────────────────
php artisan storage:link --no-interaction 2>/dev/null || true
# ─── Berechtigungen ────────────────────────────────────────────────────────────
chmod -R 775 storage bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true
# ─── Migrationen ───────────────────────────────────────────────────────────────
echo "▶ Datenbank: Migrationen ausführen ..."
php artisan migrate --force --no-interaction
echo " ✓ fertig"
# ─── Cache ─────────────────────────────────────────────────────────────────────
echo "▶ Cache aufbauen ..."
php artisan config:cache --no-interaction 2>/dev/null
php artisan route:cache --no-interaction 2>/dev/null
php artisan view:cache --no-interaction 2>/dev/null
echo " ✓ fertig"
echo ""
echo "✓ Network-MGMT läuft auf Port 80"
echo ""
# ─── Supervisor starten (nginx + php-fpm + scheduler) ─────────────────────────
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
+43
View File
@@ -0,0 +1,43 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# Datei-Upload bis 64 MB
client_max_body_size 64M;
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
# Laravel-Routen
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP-FPM
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 120;
}
# Dotfiles sperren
location ~ /\.(?!well-known) {
deny all;
}
# Logs
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
}
}
+38
View File
@@ -0,0 +1,38 @@
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
loglevel=info
; ─── PHP-FPM ───────────────────────────────────────────────────────────────────
[program:php-fpm]
command=php-fpm --nodaemonize --fpm-config /usr/local/etc/php-fpm.conf
autostart=true
autorestart=true
priority=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; ─── nginx ─────────────────────────────────────────────────────────────────────
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
priority=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; ─── Laravel Scheduler (jede Minute) ──────────────────────────────────────────
[program:scheduler]
command=bash -c "while true; do php /var/www/html/artisan schedule:run --no-ansi >> /proc/1/fd/1 2>&1; sleep 60; done"
autostart=true
autorestart=true
priority=30
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+142
View File
@@ -0,0 +1,142 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Software-Update</h2>
</x-slot>
<div class="py-8">
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8 space-y-5">
{{-- Ergebnis einer abgeschlossenen Installation --}}
@if(session('update_result') === 'success')
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg p-4">
<p class="font-semibold text-green-800 dark:text-green-300"> Update erfolgreich installiert!</p>
@if(session('update_log'))
<pre class="mt-2 text-xs text-green-700 dark:text-green-400 overflow-auto max-h-40">{{ session('update_log') }}</pre>
@endif
</div>
@elseif(session('update_result') === 'error')
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4">
<p class="font-semibold text-red-800 dark:text-red-300"> Update fehlgeschlagen</p>
@if(session('update_log'))
<pre class="mt-2 text-xs text-red-700 dark:text-red-400 overflow-auto max-h-40">{{ session('update_log') }}</pre>
@endif
</div>
@endif
{{-- Versions-Info -----------------------------------------------}}
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
<div class="px-4 py-3 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">Versionsinformationen</span>
</div>
<div class="p-4 space-y-3 text-sm">
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Installierte Version</span>
<span class="font-mono font-semibold text-gray-900 dark:text-gray-100">{{ $currentVersion }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Neueste Version (Gitea)</span>
@if($error)
<span class="text-red-500 text-xs">nicht abrufbar</span>
@elseif($latestVersion)
<span class="font-mono font-semibold {{ $updateAvailable ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400' }}">
{{ $latestVersion }}
</span>
@else
<span class="text-gray-400 text-xs"></span>
@endif
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Gitea-Repository</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">
@if($giteaUrl)
<a href="{{ $giteaUrl }}/{{ $giteaRepo }}" target="_blank"
class="text-indigo-600 hover:underline">{{ $giteaRepo }}</a>
@else
nicht konfiguriert
@endif
</span>
</div>
@if($checkedAt)
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Zuletzt geprüft</span>
<span class="text-xs text-gray-400">{{ \Carbon\Carbon::parse($checkedAt)->format('d.m.Y H:i') }}</span>
</div>
@endif
</div>
</div>
{{-- Fehler-Box --}}
@if($error)
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4 text-sm text-amber-800 dark:text-amber-300">
<p class="font-semibold"> Update-Prüfung nicht möglich</p>
<p class="mt-1 text-xs">{{ $error }}</p>
@if(!$giteaUrl)
<p class="mt-2 text-xs">In der <code class="bg-amber-100 dark:bg-amber-900 px-1 rounded">.env</code> eintragen:</p>
<pre class="mt-1 text-xs bg-amber-100 dark:bg-amber-900 rounded p-2">GITEA_URL=http://&lt;IP-des-Gitea-Servers&gt;:3000
GITEA_REPO=admin/Network-MGMT</pre>
@endif
</div>
@endif
{{-- Update verfügbar -------------------------------------------}}
@if($updateAvailable)
<div class="bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-700 rounded-lg overflow-hidden">
<div class="px-4 py-3 bg-indigo-100 dark:bg-indigo-900/40 border-b border-indigo-200 dark:border-indigo-700 flex items-center justify-between">
<span class="font-semibold text-indigo-800 dark:text-indigo-300">
🆕 Update verfügbar: {{ $currentVersion }} {{ $latestVersion }}
</span>
</div>
<div class="p-4">
@if($releaseNotes)
<div class="text-sm text-gray-700 dark:text-gray-300 mb-4 max-h-48 overflow-y-auto bg-gray-50 dark:bg-gray-900 rounded p-3 font-mono whitespace-pre-wrap">{{ $releaseNotes }}</div>
@endif
<form method="POST" action="{{ route('admin.update.install') }}"
onsubmit="return confirm('Update {{ $latestVersion }} jetzt installieren?\n\nDie App geht kurz in den Wartungsmodus.')">
@csrf
<input type="hidden" name="tag" value="{{ $latestVersion }}">
<button type="submit"
style="background-color: var(--color-primary)"
class="px-5 py-2 text-white text-sm font-semibold rounded-md hover:opacity-90 transition">
Update {{ $latestVersion }} jetzt installieren
</button>
</form>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Die App geht kurz in den Wartungsmodus. Laufende Anfragen werden danach abgeschlossen.
</p>
</div>
</div>
@elseif(!$error && $latestVersion)
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg p-4 text-sm text-green-800 dark:text-green-300">
Du verwendest bereits die neueste Version ({{ $currentVersion }}).
</div>
@endif
{{-- Manuelle Aktualisierung -----------------------------------}}
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
<div class="px-4 py-3 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">Manuell aktualisieren</span>
</div>
<div class="p-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>Um einen bestimmten Tag direkt zu installieren:</p>
<form method="POST" action="{{ route('admin.update.install') }}"
onsubmit="return confirm('Wirklich auf diesen Tag aktualisieren?')">
@csrf
<div class="flex gap-2">
<input type="text" name="tag" placeholder="z.B. v0.10.0"
class="flex-1 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" />
<button type="submit"
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">
Installieren
</button>
</div>
</form>
<p class="text-xs text-gray-400">
Alternativ per Terminal: <code class="bg-gray-100 dark:bg-gray-900 px-1 rounded">php artisan app:install-update</code>
</p>
</div>
</div>
</div>
</div>
</x-app-layout>
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="10">
<title>Update läuft bitte warten</title>
<style>
body { font-family: Arial, sans-serif; display: flex; align-items: center; justify-content: center;
height: 100vh; margin: 0; background: #f3f4f6; color: #1f2937; }
.box { text-align: center; padding: 2rem; background: white; border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,.1); max-width: 400px; }
h1 { font-size: 1.5rem; margin-bottom: .5rem; }
p { color: #6b7280; font-size: .9rem; }
.spinner { width: 40px; height: 40px; border: 4px solid #e5e7eb; border-top-color: #6366f1;
border-radius: 50%; animation: spin 1s linear infinite; margin: 1rem auto; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="box">
<div class="spinner"></div>
<h1>🔄 Update wird installiert</h1>
<p>Bitte einen Moment Geduld.<br>Die Seite lädt automatisch neu.</p>
</div>
</body>
</html>
+58 -6
View File
@@ -23,6 +23,11 @@
<button class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none
{{ request()->routeIs('admin.*') ? 'border-indigo-400 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
Einstellungen
@if($navUpdateAvailable)
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-semibold bg-amber-400 text-amber-900">
Update
</span>
@endif
<svg class="ms-1 fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
@@ -35,14 +40,48 @@
<x-dropdown-link :href="route('admin.layout.index')">
🎨 Layout
</x-dropdown-link>
<x-dropdown-link :href="route('admin.update.index')">
🔄 Software-Update
@if($navUpdateAvailable)
<span class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-semibold bg-amber-400 text-amber-900">neu</span>
@endif
</x-dropdown-link>
</x-slot>
</x-dropdown>
@endrole
{{-- Netzwerk-Link --}}
<x-nav-link :href="route('network.index')" :active="request()->routeIs('network.*')">
🌐 Netzwerk
</x-nav-link>
{{-- Netzwerk-Dropdown --}}
<x-dropdown align="left" width="48">
<x-slot name="trigger">
<button class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none
{{ request()->routeIs('network.*') ? 'border-indigo-400 text-gray-900 dark:text-gray-100' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300' }}">
🌐 Netzwerk
<svg class="ms-1 fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</x-slot>
<x-slot name="content">
<x-dropdown-link :href="route('network.dashboard')">
📊 Dashboard
</x-dropdown-link>
<x-dropdown-link :href="route('network.segments.index')">
🗂️ Segmente
</x-dropdown-link>
<x-dropdown-link :href="route('network.devices')">
💻 Alle Geräte
</x-dropdown-link>
<x-dropdown-link :href="route('network.search')">
🔍 Suche
</x-dropdown-link>
<x-dropdown-link :href="route('network.history')">
📅 IP-Verlauf
</x-dropdown-link>
<x-dropdown-link :href="route('network.import')">
⬆️ Scan importieren
</x-dropdown-link>
</x-slot>
</x-dropdown>
{{-- Hilfe-Dropdown --}}
<x-dropdown align="left" width="48">
@@ -133,8 +172,21 @@
@endrole
<div class="pt-2 pb-1 border-t border-gray-200">
<x-responsive-nav-link :href="route('network.index')" :active="request()->routeIs('network.*')">
🌐 Netzwerk
<div class="px-4 py-1 text-xs font-semibold text-gray-400 uppercase tracking-wider">Netzwerk</div>
<x-responsive-nav-link :href="route('network.dashboard')">
&nbsp;&nbsp;📊 Dashboard
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('network.segments.index')">
&nbsp;&nbsp;🗂️ Segmente
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('network.devices')">
&nbsp;&nbsp;💻 Alle Geräte
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('network.search')">
&nbsp;&nbsp;🔍 Suche
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('network.import')">
&nbsp;&nbsp;⬆️ Scan importieren
</x-responsive-nav-link>
</div>
+242
View File
@@ -0,0 +1,242 @@
<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">Netzwerk-Dashboard</h2>
<div class="flex gap-2">
<a href="{{ route('network.segments.create') }}"
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">
+ Segment anlegen
</a>
<a href="{{ route('network.import') }}" style="background-color: var(--color-primary)"
class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
+ Scan importieren
</a>
</div>
</div>
</x-slot>
<div class="py-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
{{-- KPI-Karten --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-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">Segmente</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">{{ $segments->count() }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Geräte gesamt</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">{{ $totalDevices }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Aktuell online</p>
<p class="mt-1 text-2xl font-bold text-green-600 dark:text-green-400">{{ $onlineDevices }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Offene Ereignisse</p>
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{{ $recentEvents->count() + $ipChangeEvents->count() }}
</p>
</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 --}}
<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">
<input type="text" name="q" placeholder="IP-Adresse, MAC, Hostname, Bezeichnung über alle Segmente suchen..."
class="flex-1 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" />
<button type="submit" style="background-color: var(--color-primary)"
class="px-5 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition whitespace-nowrap">
🔍 Suchen
</button>
</form>
</div>
{{-- Segmente --}}
<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 flex items-center justify-between">
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Netzwerk-Segmente</h3>
<a href="{{ route('network.segments.index') }}" class="text-xs text-indigo-600 hover:underline">Alle verwalten </a>
</div>
@if($segments->isEmpty())
<div class="px-5 py-10 text-center">
<p class="text-gray-500 dark:text-gray-400 mb-3">Noch keine Segmente definiert.</p>
<a href="{{ route('network.segments.create') }}" style="background-color: var(--color-primary)"
class="inline-block px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
Erstes Segment anlegen
</a>
</div>
@else
<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">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Subnetz</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">VLAN</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 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Online / Gesamt</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Scans</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($segments as $segment)
@php $lastScan = $latestScans->get($segment->id)?->first(); @endphp
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<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>
</td>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
<a href="{{ route('network.segments.show', $segment) }}" class="hover:text-indigo-600">
{{ $segment->name }}
</a>
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{{ $segment->subnet }}</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{{ $segment->vlan_id ? 'VLAN ' . $segment->vlan_id : '—' }}
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{{ $lastScan?->created_at->format('d.m.Y H:i') ?? '—' }}
</td>
<td class="px-4 py-3 text-right text-xs">
@if($lastScan)
<span class="text-green-600 font-medium">{{ $lastScan->online_hosts }}</span>
<span class="text-gray-400"> / {{ $lastScan->total_hosts }}</span>
@else
<span class="text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3 text-right text-xs text-gray-500 dark:text-gray-400">
{{ $segment->scans_count }}
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
{{-- Sonstige undokumentierte Ereignisse --}}
@if($recentEvents->count() > 0)
<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">
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
Undokumentierte Ereignisse
<span class="ml-2 bg-yellow-100 text-yellow-800 text-xs px-2 py-0.5 rounded-full">{{ $recentEvents->count() }}</span>
</h3>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($recentEvents as $event)
<div class="flex items-center justify-between px-5 py-3 gap-4">
<div class="flex items-center space-x-3 flex-1 min-w-0">
<span class="w-2 h-2 rounded-full flex-shrink-0
{{ $event->event_color === 'green' ? 'bg-green-500' :
($event->event_color === 'blue' ? 'bg-blue-500' :
($event->event_color === 'red' ? 'bg-red-500' : 'bg-yellow-500')) }}">
</span>
<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-xs text-gray-400 ml-2">
<a href="{{ route('network.device', $event->device) }}" class="hover:text-indigo-600">
{{ $event->device->display_name }}
</a>
· {{ $event->created_at->format('d.m.Y H:i') }}
</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>
<form method="POST" action="{{ route('network.document', $event) }}"
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>
@endforeach
</div>
</div>
@endif
</div>
</div>
</x-app-layout>
+28 -3
View File
@@ -101,10 +101,33 @@
{{-- IP-Verlauf --}}
@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="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>
@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>
{{-- 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">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
@@ -116,9 +139,11 @@
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@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 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">
<span class="text-xs {{ $h->status === 'online' ? 'text-green-600' : 'text-gray-400' }}">{{ $h->status }}</span>
</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>
+16 -1
View File
@@ -1,7 +1,7 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center space-x-2">
<a href="{{ route('network.index') }}" class="text-gray-500 hover:text-gray-700">Netzwerk</a>
<a href="{{ route('network.dashboard') }}" class="text-gray-500 hover:text-gray-700">Netzwerk</a>
<span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Scan importieren</h2>
</div>
@@ -23,6 +23,21 @@
<form method="POST" action="{{ route('network.import') }}" enctype="multipart/form-data" class="space-y-5">
@csrf
<div>
<x-input-label for="segment_id" value="Netzwerk-Segment (optional)" />
<select id="segment_id" name="segment_id"
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=""> Kein Segment </option>
@foreach($segments as $segment)
<option value="{{ $segment->id }}"
{{ (request('segment') == $segment->id || old('segment_id') == $segment->id) ? 'selected' : '' }}>
{{ $segment->name }} ({{ $segment->subnet }}){{ $segment->vlan_id ? ' · VLAN ' . $segment->vlan_id : '' }}
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('segment_id')" class="mt-2" />
</div>
<div>
<x-input-label for="scan_file" value="Angry IP Scanner Export (.txt)" />
<input id="scan_file" name="scan_file" type="file" accept=".txt,.csv" required
+97 -4
View File
@@ -1,9 +1,25 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center space-x-2">
<a href="{{ route('network.index') }}" class="text-gray-500 hover:text-gray-700">Netzwerk</a>
<span class="text-gray-400">/</span>
<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 class="flex items-center justify-between">
<div class="flex items-center space-x-2">
@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>
@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>
</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>
@@ -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">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">Bemerkung</th>
</tr>
</thead>
<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->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 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>
@endforeach
</tbody>
@@ -61,4 +97,61 @@
</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>
+149
View File
@@ -0,0 +1,149 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Globale Suche</h2>
</x-slot>
<div class="py-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-4">
<form method="GET" action="{{ route('network.search') }}" class="flex gap-3">
<input type="text" name="q" value="{{ $q }}"
placeholder="IP-Adresse, MAC, Hostname, Bezeichnung..."
autofocus
class="flex-1 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" />
<button type="submit" style="background-color: var(--color-primary)"
class="px-5 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
🔍 Suchen
</button>
@if($q)
<a href="{{ route('network.search') }}"
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">
Zurücksetzen
</a>
@endif
</form>
@if($q && strlen($q) < 2)
<p class="text-sm text-gray-500">Mindestens 2 Zeichen eingeben.</p>
@elseif($q)
@php $totalFound = $devices->total() + $hostResults->count(); @endphp
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ $totalFound }} Ergebnis(se) für {{ $q }}" über alle Segmente
</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="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">
<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 / Bezeichnung</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">Zuletzt gesehen</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($devices as $device)
<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
{{ $device->status === 'online' ? 'bg-green-500' : 'bg-red-400' }}"></span>
</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 text-gray-900 dark:text-gray-100">
@if($device->label)
<span class="font-medium">{{ $device->label }}</span>
<span class="text-gray-400 text-xs ml-1">({{ $device->hostname }})</span>
@else
{{ $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
</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-500 dark:text-gray-400">
{{ $device->last_seen_at?->format('d.m.Y H:i') ?? '—' }}
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('network.device', $device) }}"
class="text-xs text-indigo-600 hover:underline">Detail </a>
</td>
</tr>
@endforeach
</tbody>
</table>
@if($devices->hasPages())
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
{{ $devices->links() }}
</div>
@endif
</div>
@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>
</x-app-layout>
@@ -0,0 +1,129 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center space-x-2">
<a href="{{ route('network.segments.index') }}" class="text-gray-500 hover:text-gray-700">Segmente</a>
<span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Neues Segment</h2>
</div>
</x-slot>
<div class="py-8">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-6">
<form method="POST" action="{{ route('network.segments.store') }}" class="space-y-5">
@csrf
<div>
<x-input-label for="name" value="Name *" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
value="{{ old('name') }}" placeholder="z.B. Büro, Server-VLAN, Produktion" required />
<x-input-error :messages="$errors->get('name')" class="mt-1" />
</div>
<div>
<x-input-label for="subnet" value="Subnetz *" />
<div class="flex gap-2 mt-1">
<x-text-input id="subnet" name="subnet" type="text" class="block w-full font-mono"
value="{{ old('subnet') }}" placeholder="z.B. 192.168.1.0/24 oder 10.0.0.0/8" required />
<button type="button" id="detect-subnet-btn"
class="shrink-0 px-3 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
🔍 Erkennen
</button>
</div>
<x-input-error :messages="$errors->get('subnet')" class="mt-1" />
<div id="subnet-suggestions" class="mt-1 hidden"></div>
</div>
<div>
<x-input-label for="vlan_id" value="VLAN-ID (optional)" />
<x-text-input id="vlan_id" name="vlan_id" type="number" class="mt-1 block w-full"
value="{{ old('vlan_id') }}" placeholder="z.B. 10" min="1" max="4094" />
<x-input-error :messages="$errors->get('vlan_id')" class="mt-1" />
</div>
<div>
<x-input-label for="description" value="Beschreibung (optional)" />
<textarea id="description" name="description" rows="2"
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"
placeholder="Kurze Beschreibung dieses Segments...">{{ old('description') }}</textarea>
</div>
<div>
<x-input-label for="scan_interval_minutes" value="Scan-Zyklus (automatisch)" />
<select id="scan_interval_minutes" name="scan_interval_minutes"
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Manuell (kein Auto-Scan)</option>
<option value="5" {{ old('scan_interval_minutes') == '5' ? 'selected' : '' }}>Alle 5 Minuten</option>
<option value="15" {{ old('scan_interval_minutes') == '15' ? 'selected' : '' }}>Alle 15 Minuten</option>
<option value="30" {{ old('scan_interval_minutes') == '30' ? 'selected' : '' }}>Alle 30 Minuten</option>
<option value="60" {{ old('scan_interval_minutes') == '60' ? 'selected' : '' }}>Stündlich</option>
<option value="360" {{ old('scan_interval_minutes') == '360' ? 'selected' : '' }}>Alle 6 Stunden</option>
<option value="720" {{ old('scan_interval_minutes') == '720' ? 'selected' : '' }}>Alle 12 Stunden</option>
<option value="1440" {{ old('scan_interval_minutes') == '1440' ? 'selected' : '' }}>Täglich</option>
</select>
<p class="mt-1 text-xs text-gray-500">Erfordert aktiven Laravel Scheduler (Cron).</p>
</div>
<div>
<x-input-label for="nmap_options" value="nmap-Parameter" />
<x-text-input id="nmap_options" name="nmap_options" type="text" class="mt-1 block w-full font-mono"
value="{{ old('nmap_options', '-sn') }}" placeholder="-sn" />
<p class="mt-1 text-xs text-gray-500">-sn = Ping-Scan (schnell) · -sn -p 22,80,443 = mit Ports</p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="active" name="active" value="1"
{{ old('active', '1') ? 'checked' : '' }}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<label for="active" class="text-sm text-gray-700 dark:text-gray-300">Aktiv (automatisch überwachen)</label>
</div>
<div class="flex justify-end gap-3">
<a href="{{ route('network.segments.index') }}"
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">
Abbrechen
</a>
<button type="submit" style="background-color: var(--color-primary)"
class="px-5 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
Segment anlegen
</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.getElementById('detect-subnet-btn').addEventListener('click', function() {
const btn = this;
const suggestions = document.getElementById('subnet-suggestions');
btn.textContent = '⏳ Suche...';
btn.disabled = true;
fetch('{{ route('network.detect-subnets') }}', {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(function(subnets) {
btn.textContent = '🔍 Erkennen';
btn.disabled = false;
if (!subnets.length) {
suggestions.innerHTML = '<p class="text-xs text-gray-400">Keine Daten in der Datenbank gefunden.</p>';
suggestions.classList.remove('hidden');
return;
}
suggestions.innerHTML = '<p class="text-xs text-gray-500 mb-1">Erkannte Subnetze klicken zum Übernehmen:</p>' +
subnets.map(function(s) {
return '<button type="button" onclick="document.getElementById(\'subnet\').value=\'' + s + '\'" ' +
'class="mr-1 mb-1 px-2 py-1 text-xs font-mono rounded border border-indigo-300 text-indigo-700 hover:bg-indigo-50 transition">' + s + '</button>';
}).join('');
suggestions.classList.remove('hidden');
})
.catch(function() {
btn.textContent = '🔍 Erkennen';
btn.disabled = false;
suggestions.innerHTML = '<p class="text-xs text-red-500">Fehler bei der Erkennung.</p>';
suggestions.classList.remove('hidden');
});
});
</script>
</x-app-layout>
@@ -0,0 +1,125 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center space-x-2">
<a href="{{ route('network.segments.index') }}" class="text-gray-500 hover:text-gray-700">Segmente</a>
<span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">{{ $segment->name }} bearbeiten</h2>
</div>
</x-slot>
<div class="py-8">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-6">
<form method="POST" action="{{ route('network.segments.update', $segment) }}" class="space-y-5">
@csrf @method('PUT')
<div>
<x-input-label for="name" value="Name *" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
value="{{ old('name', $segment->name) }}" required />
<x-input-error :messages="$errors->get('name')" class="mt-1" />
</div>
<div>
<x-input-label for="subnet" value="Subnetz *" />
<div class="flex gap-2 mt-1">
<x-text-input id="subnet" name="subnet" type="text" class="block w-full font-mono"
value="{{ old('subnet', $segment->subnet) }}" required />
<button type="button" id="detect-subnet-btn"
class="shrink-0 px-3 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
🔍 Erkennen
</button>
</div>
<x-input-error :messages="$errors->get('subnet')" class="mt-1" />
<div id="subnet-suggestions" class="mt-1 hidden"></div>
</div>
<div>
<x-input-label for="vlan_id" value="VLAN-ID (optional)" />
<x-text-input id="vlan_id" name="vlan_id" type="number" class="mt-1 block w-full"
value="{{ old('vlan_id', $segment->vlan_id) }}" min="1" max="4094" />
<x-input-error :messages="$errors->get('vlan_id')" class="mt-1" />
</div>
<div>
<x-input-label for="description" value="Beschreibung (optional)" />
<textarea id="description" name="description" rows="2"
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">{{ old('description', $segment->description) }}</textarea>
</div>
<div>
<x-input-label for="scan_interval_minutes" value="Scan-Zyklus (automatisch)" />
<select id="scan_interval_minutes" name="scan_interval_minutes"
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Manuell (kein Auto-Scan)</option>
@foreach([5 => 'Alle 5 Minuten', 15 => 'Alle 15 Minuten', 30 => 'Alle 30 Minuten', 60 => 'Stündlich', 360 => 'Alle 6 Stunden', 720 => 'Alle 12 Stunden', 1440 => 'Täglich'] as $val => $label)
<option value="{{ $val }}" {{ old('scan_interval_minutes', $segment->scan_interval_minutes) == $val ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div>
<x-input-label for="nmap_options" value="nmap-Parameter" />
<x-text-input id="nmap_options" name="nmap_options" type="text" class="mt-1 block w-full font-mono"
value="{{ old('nmap_options', $segment->nmap_options) }}" placeholder="-sn" />
<p class="mt-1 text-xs text-gray-500">-sn = Ping-Scan · -sn -p 22,80,443 = mit Ports</p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="active" name="active" value="1"
{{ old('active', $segment->active) ? 'checked' : '' }}
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<label for="active" class="text-sm text-gray-700 dark:text-gray-300">Aktiv</label>
</div>
<div class="flex justify-end gap-3">
<a href="{{ route('network.segments.index') }}"
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">
Abbrechen
</a>
<button type="submit" style="background-color: var(--color-primary)"
class="px-5 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
Speichern
</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.getElementById('detect-subnet-btn').addEventListener('click', function() {
const btn = this;
const suggestions = document.getElementById('subnet-suggestions');
btn.textContent = '⏳ Suche...';
btn.disabled = true;
fetch('{{ route('network.detect-subnets') }}', {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(function(subnets) {
btn.textContent = '🔍 Erkennen';
btn.disabled = false;
if (!subnets.length) {
suggestions.innerHTML = '<p class="text-xs text-gray-400">Keine Daten in der Datenbank gefunden.</p>';
suggestions.classList.remove('hidden');
return;
}
suggestions.innerHTML = '<p class="text-xs text-gray-500 mb-1">Erkannte Subnetze klicken zum Übernehmen:</p>' +
subnets.map(function(s) {
return '<button type="button" onclick="document.getElementById(\'subnet\').value=\'' + s + '\'" ' +
'class="mr-1 mb-1 px-2 py-1 text-xs font-mono rounded border border-indigo-300 text-indigo-700 hover:bg-indigo-50 transition">' + s + '</button>';
}).join('');
suggestions.classList.remove('hidden');
})
.catch(function() {
btn.textContent = '🔍 Erkennen';
btn.disabled = false;
suggestions.innerHTML = '<p class="text-xs text-red-500">Fehler bei der Erkennung.</p>';
suggestions.classList.remove('hidden');
});
});
</script>
</x-app-layout>
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Segment {{ $segment->name }} Export</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, sans-serif;
font-size: 11px;
color: #1f2937;
padding: 20px;
background: white;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid #e5e7eb;
}
h1 { font-size: 18px; font-weight: bold; color: #111827; }
.meta {
font-size: 11px;
color: #6b7280;
margin-top: 4px;
line-height: 1.5;
}
.print-btn {
background: #4f46e5;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.print-btn:hover { background: #4338ca; }
table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
}
thead th {
background-color: #f3f4f6;
border: 1px solid #d1d5db;
padding: 6px 8px;
text-align: left;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
color: #6b7280;
letter-spacing: 0.05em;
}
tbody td {
border: 1px solid #e5e7eb;
padding: 5px 8px;
vertical-align: top;
}
tbody tr:nth-child(even) td { background-color: #f9fafb; }
tbody tr:hover td { background-color: #eff6ff; }
.online { color: #16a34a; font-weight: 700; }
.offline { color: #9ca3af; }
.mono { font-family: 'Courier New', monospace; }
.note { color: #4b5563; font-style: italic; }
.footer {
margin-top: 12px;
font-size: 9px;
color: #9ca3af;
text-align: right;
}
/* Print-Stile */
@media print {
.no-print { display: none !important; }
body { padding: 0; font-size: 10px; }
thead th { background-color: #f3f4f6 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
tbody tr:nth-child(even) td { background-color: #f9fafb !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@page { size: A4 landscape; margin: 1.5cm; }
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>🌐 Segment: {{ $segment->name }}</h1>
<div class="meta">
Subnetz: <strong>{{ $segment->subnet }}</strong>
@if($segment->vlan_id) &nbsp;·&nbsp; VLAN {{ $segment->vlan_id }} @endif
&nbsp;·&nbsp; {{ $hosts->count() }} Hosts
&nbsp;·&nbsp; {{ $hosts->where('status', 'online')->count() }} online
<br>
Exportiert: {{ now()->format('d.m.Y H:i') }}
@if($segment->description) &nbsp;·&nbsp; {{ $segment->description }} @endif
</div>
</div>
<button class="print-btn no-print" onclick="window.print()">🖨️ Als PDF drucken</button>
</div>
<table>
<thead>
<tr>
<th style="width:7%">Status</th>
<th style="width:12%">IP-Adresse</th>
<th style="width:16%">MAC-Adresse</th>
<th style="width:18%">Hostname</th>
<th style="width:14%">Hersteller</th>
<th style="width:7%">Ping</th>
<th>Bemerkung</th>
</tr>
</thead>
<tbody>
@forelse($hosts as $host)
<tr>
<td class="{{ $host->status === 'online' ? 'online' : 'offline' }}">
{{ $host->status === 'online' ? '● Online' : '○ Offline' }}
</td>
<td class="mono">{{ $host->ip_address }}</td>
<td class="mono">{{ $host->mac_address ?? '—' }}</td>
<td>{{ $host->hostname ?? '—' }}</td>
<td>{{ $host->mac_vendor ?? '—' }}</td>
<td class="mono">{{ $host->ping_ms !== null ? $host->ping_ms . ' ms' : '—' }}</td>
<td class="note">{{ $notes[$host->ip_address] ?? '' }}</td>
</tr>
@empty
<tr>
<td colspan="7" style="text-align:center; padding:24px; color:#9ca3af;">
Keine Hosts vorhanden.
</td>
</tr>
@endforelse
</tbody>
</table>
<div class="footer">
Network-MGMT · MMS System Service · {{ now()->format('d.m.Y H:i') }}
</div>
<script>
// Automatisch Druckdialog öffnen (nur wenn direkt als PDF aufgerufen)
if (window.location.pathname.includes('/export/pdf')) {
window.addEventListener('load', function() {
setTimeout(function() { window.print(); }, 300);
});
}
</script>
</body>
</html>
@@ -0,0 +1,77 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<a href="{{ route('network.dashboard') }}" class="text-gray-500 hover:text-gray-700">Netzwerk</a>
<span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Segmente</h2>
</div>
<a href="{{ route('network.segments.create') }}" style="background-color: var(--color-primary)"
class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
+ Segment anlegen
</a>
</div>
</x-slot>
<div class="py-8">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
@if(session('success'))
<div class="mb-4 p-4 bg-green-100 text-green-800 rounded-md">{{ session('success') }}</div>
@endif
<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-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Aktiv</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Subnetz</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">VLAN</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Scans</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Beschreibung</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@forelse($segments as $segment)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<td class="px-4 py-3">
<span class="inline-block w-2.5 h-2.5 rounded-full {{ $segment->active ? 'bg-green-500' : 'bg-gray-300' }}"></span>
</td>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
<a href="{{ route('network.segments.show', $segment) }}" class="hover:text-indigo-600">
{{ $segment->name }}
</a>
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{{ $segment->subnet }}</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{{ $segment->vlan_id ? 'VLAN ' . $segment->vlan_id : '—' }}
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{{ $segment->scans_count }}</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 max-w-xs truncate">
{{ $segment->description ?? '—' }}
</td>
<td class="px-4 py-3 text-right space-x-3">
<a href="{{ route('network.segments.edit', $segment) }}"
class="text-xs text-indigo-600 hover:underline">Bearbeiten</a>
<form method="POST" action="{{ route('network.segments.destroy', $segment) }}"
class="inline" onsubmit="return confirm('Segment löschen?')">
@csrf @method('DELETE')
<button type="submit" class="text-xs text-red-500 hover:underline">Löschen</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-10 text-center text-gray-500">
Noch keine Segmente. <a href="{{ route('network.segments.create') }}" class="text-indigo-600 hover:underline">Jetzt anlegen</a>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</x-app-layout>
@@ -0,0 +1,174 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<a href="{{ route('network.segments.index') }}" class="text-gray-500 hover:text-gray-700">Segmente</a>
<span class="text-gray-400">/</span>
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">{{ $segment->name }}</h2>
</div>
<div class="flex gap-2 items-center">
{{-- Auto-Refresh Indikator --}}
<span id="refresh-indicator" class="text-xs text-gray-400 dark:text-gray-500 hidden">
wird aktualisiert...
</span>
{{-- Jetzt scannen --}}
@if($segment->active)
<form method="POST" action="{{ route('network.segments.scan', $segment) }}">
@csrf
<button type="submit" style="background-color: var(--color-primary)"
class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
Jetzt scannen
</button>
</form>
@endif
<a href="{{ route('network.import') }}?segment={{ $segment->id }}"
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
Import
</a>
<a href="{{ route('network.segments.export.xlsx', $segment) }}"
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
📊 Excel
</a>
<a href="{{ route('network.segments.export.pdf', $segment) }}"
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
📄 PDF
</a>
<a href="{{ route('network.segments.edit', $segment) }}"
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
Bearbeiten
</a>
</div>
</div>
</x-slot>
<div class="py-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
{{-- Segment-Info --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-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">Subnetz</p>
<p class="mt-1 font-mono font-bold text-gray-900 dark:text-gray-100">{{ $segment->subnet }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">VLAN</p>
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100">{{ $segment->vlan_id ? 'VLAN ' . $segment->vlan_id : '—' }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</p>
<p class="mt-1 font-bold {{ $segment->active ? 'text-green-600' : 'text-gray-400' }}">
{{ $segment->active ? 'Aktiv' : 'Inaktiv' }}
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Auto-Scan</p>
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100 text-sm">
{{ $segment->scan_interval_label }}
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Scans gesamt</p>
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100">{{ $scans->total() }}</p>
</div>
</div>
@if($latestScan)
<div class="grid grid-cols-2 md:grid-cols-3 gap-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">Letzter Scan</p>
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100">{{ $latestScan->created_at->format('d.m.Y H:i') }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Online / Gesamt</p>
<p class="mt-1 font-bold">
<span class="text-green-600">{{ $latestScan->online_hosts }}</span>
<span class="text-gray-400"> / {{ $latestScan->total_hosts }}</span>
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Neue Geräte</p>
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100">{{ $latestScan->new_devices }}</p>
</div>
</div>
@endif
{{-- Scan-Historie --}}
<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">
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Scan-Historie</h3>
</div>
<div data-scan-count="{{ $scans->total() }}"></div>
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Datum</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Scanner</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Gesamt</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Online</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Neu</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Geändert</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@forelse($scans as $scan)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ $scan->created_at->format('d.m.Y H:i') }}</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{{ $scan->scanner ?? '—' }}</td>
<td class="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{{ $scan->total_hosts }}</td>
<td class="px-4 py-3 text-right text-green-600 font-medium">{{ $scan->online_hosts }}</td>
<td class="px-4 py-3 text-right text-blue-600">{{ $scan->new_devices }}</td>
<td class="px-4 py-3 text-right text-yellow-600">{{ $scan->changed_devices }}</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('network.scan', $scan) }}"
class="text-xs text-indigo-600 hover:underline">Details </a>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-10 text-center text-gray-500">Noch keine Scans für dieses Segment.</td>
</tr>
@endforelse
</tbody>
</table>
@if($scans->hasPages())
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
{{ $scans->links() }}
</div>
@endif
</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>
+14
View File
@@ -1,8 +1,22 @@
<?php
use App\Models\NetworkSegment;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(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();
// Update-Prüfung alle 6 Stunden
Schedule::command('app:check-update')
->everySixHours()
->runInBackground();
+37 -1
View File
@@ -3,8 +3,10 @@
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\HelpController;
use App\Http\Controllers\NetworkController;
use App\Http\Controllers\NetworkSegmentController;
use App\Http\Controllers\Admin\UserController as AdminUserController;
use App\Http\Controllers\Admin\LayoutController as AdminLayoutController;
use App\Http\Controllers\Admin\UpdateController as AdminUpdateController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
@@ -31,6 +33,10 @@ Route::prefix('admin')
Route::get('layout', [AdminLayoutController::class, 'index'])->name('layout.index');
Route::put('layout', [AdminLayoutController::class, 'update'])->name('layout.update');
Route::get('layout/remove-logo', [AdminLayoutController::class, 'removeLogo'])->name('layout.removeLogo');
// Software-Update
Route::get('update', [AdminUpdateController::class, 'index'])->name('update.index');
Route::post('update/install', [AdminUpdateController::class, 'install'])->name('update.install');
});
// Netzwerk-Bereich für alle eingeloggten Benutzer
@@ -38,14 +44,44 @@ Route::prefix('network')
->name('network.')
->middleware(['auth'])
->group(function () {
Route::get('/', [NetworkController::class, 'index'])->name('index');
// Dashboard
Route::get('/', [NetworkController::class, 'dashboard'])->name('dashboard');
// Subnetz-Erkennung (JSON)
Route::get('/detect-subnets', [NetworkController::class, 'detectSubnets'])->name('detect-subnets');
// Globale Suche
Route::get('/search', [NetworkController::class, 'search'])->name('search');
// Segmente (CRUD + Scan auslösen)
Route::resource('segments', NetworkSegmentController::class)
->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
Route::get('/devices', [NetworkController::class, 'devices'])->name('devices');
Route::get('/devices/{device}', [NetworkController::class, 'device'])->name('device');
Route::put('/devices/{device}', [NetworkController::class, 'updateDevice'])->name('device.update');
Route::post('/devices/{device}/note', [NetworkController::class, 'addNote'])->name('device.note');
// Ereignisse
Route::post('/events/{event}/document', [NetworkController::class, 'documentEvent'])->name('document');
// Import
Route::get('/import', [NetworkController::class, 'showImport'])->name('import');
Route::post('/import', [NetworkController::class, 'import'])->name('import');
// Scan-Detail
Route::get('/scans/{scan}', [NetworkController::class, 'scan'])->name('scan');
});