Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dc9c2c42d | |||
| af2aa1eaf5 | |||
| 85118c5bcc |
@@ -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
@@ -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
|
||||
|
||||
+99
-1
@@ -9,6 +9,100 @@ 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
|
||||
@@ -95,7 +189,11 @@ Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/).
|
||||
- Grundlegende PHP-Projektstruktur (public/, src/, config/)
|
||||
- composer.json, .gitignore, README.md
|
||||
|
||||
[Unreleased]: http://localhost:3000/admin/Network-MGMT/compare/v0.6.0...HEAD
|
||||
[Unreleased]: http://localhost:3000/admin/Network-MGMT/compare/v0.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
|
||||
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,26 +4,53 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\NetworkDevice;
|
||||
use App\Models\NetworkDeviceEvent;
|
||||
use App\Models\NetworkHost;
|
||||
use App\Models\NetworkScan;
|
||||
use App\Models\NetworkSegment;
|
||||
use App\Services\NetworkScanImporter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NetworkController extends Controller
|
||||
{
|
||||
// --- Subnetz-Erkennung aus vorhandenen IP-Daten ---
|
||||
public function detectSubnets(): JsonResponse
|
||||
{
|
||||
$subnets = DB::table('network_hosts')
|
||||
->whereNotNull('ip_address')
|
||||
->select(DB::raw("CONCAT(SUBSTRING_INDEX(ip_address, '.', 3), '.0/24') as subnet"))
|
||||
->distinct()
|
||||
->orderBy('subnet')
|
||||
->pluck('subnet')
|
||||
->toArray();
|
||||
|
||||
return response()->json($subnets);
|
||||
}
|
||||
|
||||
// --- Dashboard ---
|
||||
public function dashboard(): View
|
||||
{
|
||||
$segments = NetworkSegment::withCount('scans')->orderBy('name')->get();
|
||||
$totalDevices = NetworkDevice::count();
|
||||
$onlineDevices = NetworkDevice::where('status', 'online')->count();
|
||||
$recentEvents = NetworkDeviceEvent::with('device')
|
||||
|
||||
// IP-Wechsel separat → prominenter Block oben
|
||||
$ipChangeEvents = NetworkDeviceEvent::with('device')
|
||||
->where('documented', false)
|
||||
->where('event_type', 'ip_changed')
|
||||
->latest()
|
||||
->take(10)
|
||||
->get();
|
||||
|
||||
// Alle anderen undokumentierten Ereignisse
|
||||
$recentEvents = NetworkDeviceEvent::with('device')
|
||||
->where('documented', false)
|
||||
->where('event_type', '!=', 'ip_changed')
|
||||
->latest()
|
||||
->take(15)
|
||||
->get();
|
||||
|
||||
// Letzten Scan pro Segment
|
||||
@@ -33,31 +60,113 @@ class NetworkController extends Controller
|
||||
->groupBy('segment_id');
|
||||
|
||||
return view('network.dashboard', compact(
|
||||
'segments', 'totalDevices', 'onlineDevices', 'recentEvents', 'latestScans'
|
||||
'segments', 'totalDevices', 'onlineDevices',
|
||||
'recentEvents', 'ipChangeEvents', 'latestScans'
|
||||
));
|
||||
}
|
||||
|
||||
// --- Chronologischer IP-Verlauf ---
|
||||
public function history(Request $request): View
|
||||
{
|
||||
$query = \App\Models\NetworkHost::query()
|
||||
->join('network_scans', 'network_scans.id', '=', 'network_hosts.scan_id')
|
||||
->leftJoin('network_segments', 'network_segments.id', '=', 'network_scans.segment_id')
|
||||
->leftJoin('network_devices', 'network_devices.id', '=', 'network_hosts.device_id')
|
||||
->select([
|
||||
'network_hosts.*',
|
||||
'network_scans.created_at as scan_time',
|
||||
'network_scans.segment_id',
|
||||
'network_scans.scanner',
|
||||
'network_segments.name as segment_name',
|
||||
'network_devices.label as device_label',
|
||||
]);
|
||||
|
||||
if ($request->filled('ip')) {
|
||||
$query->where('network_hosts.ip_address', 'like', '%' . $request->ip . '%');
|
||||
}
|
||||
if ($request->filled('mac')) {
|
||||
$query->where('network_hosts.mac_address', 'like', '%' . $request->mac . '%');
|
||||
}
|
||||
if ($request->filled('hostname')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('network_hosts.hostname', 'like', '%' . $request->hostname . '%')
|
||||
->orWhere('network_devices.label', 'like', '%' . $request->hostname . '%');
|
||||
});
|
||||
}
|
||||
if ($request->filled('segment')) {
|
||||
$query->where('network_scans.segment_id', $request->segment);
|
||||
}
|
||||
if ($request->filled('status')) {
|
||||
$query->where('network_hosts.status', $request->status);
|
||||
}
|
||||
if ($request->filled('from')) {
|
||||
$query->where('network_scans.created_at', '>=', $request->from . ' 00:00:00');
|
||||
}
|
||||
if ($request->filled('to')) {
|
||||
$query->where('network_scans.created_at', '<=', $request->to . ' 23:59:59');
|
||||
}
|
||||
|
||||
$entries = $query->orderByDesc('network_scans.created_at')
|
||||
->orderByRaw('INET_ATON(network_hosts.ip_address)')
|
||||
->paginate(100)
|
||||
->withQueryString();
|
||||
|
||||
$segments = \App\Models\NetworkSegment::orderBy('name')->get();
|
||||
|
||||
return view('network.history', compact('entries', 'segments'));
|
||||
}
|
||||
|
||||
// --- Globale Suche ---
|
||||
public function search(Request $request): View
|
||||
{
|
||||
$q = trim($request->get('q', ''));
|
||||
$devices = collect();
|
||||
$hostResults = collect();
|
||||
|
||||
if (strlen($q) >= 2) {
|
||||
// Suche in network_devices (MAC-basiert / bereits getracked)
|
||||
$devices = NetworkDevice::with('events')
|
||||
->where(function ($query) use ($q) {
|
||||
$query->where('current_ip', 'like', "%{$q}%")
|
||||
$query->where('current_ip', 'like', "%{$q}%")
|
||||
->orWhere('mac_address', 'like', "%{$q}%")
|
||||
->orWhere('hostname', 'like', "%{$q}%")
|
||||
->orWhere('netbios_name','like', "%{$q}%")
|
||||
->orWhere('label', 'like', "%{$q}%")
|
||||
->orWhere('mac_vendor', 'like', "%{$q}%");
|
||||
})
|
||||
->orderBy('current_ip')
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
|
||||
// Zusätzlich: direkte Suche in network_hosts (auch ohne Device-Eintrag)
|
||||
// Zeigt IPs/Hosts die in Scans gefunden wurden, aber noch kein Device-Record haben
|
||||
$hostResults = NetworkHost::query()
|
||||
->join('network_scans', 'network_scans.id', '=', 'network_hosts.scan_id')
|
||||
->leftJoin('network_segments', 'network_segments.id', '=', 'network_scans.segment_id')
|
||||
->whereNull('network_hosts.device_id')
|
||||
->where(function ($query) use ($q) {
|
||||
$query->where('network_hosts.ip_address', 'like', "%{$q}%")
|
||||
->orWhere('network_hosts.mac_address', 'like', "%{$q}%")
|
||||
->orWhere('network_hosts.hostname', 'like', "%{$q}%");
|
||||
})
|
||||
->select([
|
||||
'network_hosts.ip_address',
|
||||
'network_hosts.mac_address',
|
||||
'network_hosts.hostname',
|
||||
'network_hosts.mac_vendor',
|
||||
'network_hosts.status',
|
||||
'network_hosts.ping_ms',
|
||||
'network_scans.created_at as scan_time',
|
||||
'network_segments.name as segment_name',
|
||||
DB::raw('network_scans.id as scan_id'),
|
||||
])
|
||||
->orderByDesc('network_scans.created_at')
|
||||
->limit(50)
|
||||
->get()
|
||||
->unique('ip_address'); // Jede IP nur einmal (neuester Fund)
|
||||
}
|
||||
|
||||
return view('network.search', compact('q', 'devices'));
|
||||
return view('network.search', compact('q', 'devices', 'hostResults'));
|
||||
}
|
||||
|
||||
// --- Alte index-Route umleiten ---
|
||||
@@ -203,6 +312,12 @@ class NetworkController extends Controller
|
||||
->orderByRaw("INET_ATON(ip_address)")
|
||||
->get();
|
||||
|
||||
return view('network.scan', compact('scan', 'hosts'));
|
||||
$notes = collect();
|
||||
if ($scan->segment_id) {
|
||||
$notes = \App\Models\NetworkIpNote::where('segment_id', $scan->segment_id)
|
||||
->pluck('note', 'ip_address');
|
||||
}
|
||||
|
||||
return view('network.scan', compact('scan', 'hosts', 'notes'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\NetworkIpNote;
|
||||
use App\Models\NetworkSegment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NetworkSegmentController extends Controller
|
||||
@@ -80,4 +84,96 @@ class NetworkSegmentController extends Controller
|
||||
return redirect()->route('network.segments.index')
|
||||
->with('success', "Segment \"{$name}\" gelöscht.");
|
||||
}
|
||||
|
||||
public function triggerScan(NetworkSegment $segment): RedirectResponse
|
||||
{
|
||||
if (!$segment->active) {
|
||||
return back()->with('error', 'Segment ist inaktiv.');
|
||||
}
|
||||
|
||||
// Scan im Hintergrund starten (non-blocking)
|
||||
$projectPath = base_path();
|
||||
$phpBinary = PHP_BINARY;
|
||||
$cmd = "cd {$projectPath} && {$phpBinary} artisan network:scan {$segment->id} --force > /dev/null 2>&1 &";
|
||||
exec($cmd);
|
||||
|
||||
return redirect()->route('network.segments.show', $segment)
|
||||
->with('success', "Scan für \"{$segment->name}\" wurde gestartet. Ergebnisse erscheinen in Kürze.");
|
||||
}
|
||||
|
||||
// --- IP-Notiz speichern (Upsert per Segment + IP) ---
|
||||
public function saveIpNote(Request $request, NetworkSegment $segment): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'ip_address' => ['required', 'ip'],
|
||||
'note' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
NetworkIpNote::updateOrCreate(
|
||||
['segment_id' => $segment->id, 'ip_address' => $request->ip_address],
|
||||
[
|
||||
'note' => $request->note,
|
||||
'updated_by' => auth()->id(),
|
||||
'created_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// --- Export: Excel (OpenSpout – kein ext-gd, PHP 8.5 kompatibel) ---
|
||||
public function exportXlsx(NetworkSegment $segment)
|
||||
{
|
||||
$hosts = $this->getLatestScanHosts($segment);
|
||||
$notes = NetworkIpNote::where('segment_id', $segment->id)
|
||||
->pluck('note', 'ip_address');
|
||||
|
||||
$filename = 'Segment_' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $segment->name)
|
||||
. '_' . now()->format('Ymd_Hi') . '.xlsx';
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'net_export_') . '.xlsx';
|
||||
|
||||
$writer = new \OpenSpout\Writer\XLSX\Writer();
|
||||
$writer->openToFile($tempFile);
|
||||
|
||||
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues([
|
||||
'IP-Adresse', 'Status', 'Hostname', 'MAC-Adresse', 'Hersteller', 'Ping (ms)', 'Bemerkung',
|
||||
]));
|
||||
|
||||
foreach ($hosts as $host) {
|
||||
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues([
|
||||
$host->ip_address,
|
||||
$host->status,
|
||||
$host->hostname ?? '',
|
||||
$host->mac_address ?? '',
|
||||
$host->mac_vendor ?? '',
|
||||
$host->ping_ms !== null ? (string) $host->ping_ms : '',
|
||||
$notes[$host->ip_address] ?? '',
|
||||
]));
|
||||
}
|
||||
|
||||
$writer->close();
|
||||
|
||||
return response()->download($tempFile, $filename, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
])->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
// --- Export: PDF (Browser-Print-View – kein PHP-PDF-Package nötig) ---
|
||||
public function exportPdf(NetworkSegment $segment)
|
||||
{
|
||||
$hosts = $this->getLatestScanHosts($segment);
|
||||
$notes = NetworkIpNote::where('segment_id', $segment->id)
|
||||
->pluck('note', 'ip_address');
|
||||
|
||||
return view('network.segments.export-pdf', compact('segment', 'hosts', 'notes'));
|
||||
}
|
||||
|
||||
private function getLatestScanHosts(NetworkSegment $segment)
|
||||
{
|
||||
$latestScan = $segment->scans()->latest()->first();
|
||||
if (!$latestScan) {
|
||||
return collect();
|
||||
}
|
||||
return $latestScan->hosts()->orderByRaw('INET_ATON(ip_address)')->get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,44 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
class NetworkSegment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name', 'subnet', 'vlan_id', 'active', 'description', 'created_by',
|
||||
'name', 'subnet', 'vlan_id', 'active', 'description',
|
||||
'scan_interval_minutes', 'last_scanned_at', 'nmap_options', 'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
'vlan_id' => 'integer',
|
||||
'active' => 'boolean',
|
||||
'vlan_id' => 'integer',
|
||||
'scan_interval_minutes' => 'integer',
|
||||
'last_scanned_at' => 'datetime',
|
||||
];
|
||||
|
||||
/** Ist ein automatischer Scan fällig? */
|
||||
public function isScanDue(): bool
|
||||
{
|
||||
if (!$this->active || !$this->scan_interval_minutes) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->last_scanned_at) {
|
||||
return true;
|
||||
}
|
||||
return $this->last_scanned_at->addMinutes($this->scan_interval_minutes)->isPast();
|
||||
}
|
||||
|
||||
/** Lesbare Intervall-Beschreibung */
|
||||
public function getScanIntervalLabelAttribute(): string
|
||||
{
|
||||
return match($this->scan_interval_minutes) {
|
||||
5 => 'alle 5 Minuten',
|
||||
15 => 'alle 15 Minuten',
|
||||
30 => 'alle 30 Minuten',
|
||||
60 => 'stündlich',
|
||||
360 => 'alle 6 Stunden',
|
||||
720 => 'alle 12 Stunden',
|
||||
1440 => 'täglich',
|
||||
default => 'manuell',
|
||||
};
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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::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');
|
||||
}
|
||||
};
|
||||
+23
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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: 3–5 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 ""
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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://<IP-des-Gitea-Servers>: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>
|
||||
@@ -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,6 +40,12 @@
|
||||
<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
|
||||
@@ -63,6 +74,9 @@
|
||||
<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>
|
||||
|
||||
@@ -34,10 +34,83 @@
|
||||
</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() }}</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">
|
||||
@@ -80,9 +153,7 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@foreach($segments as $segment)
|
||||
@php
|
||||
$lastScan = $latestScans->get($segment->id)?->first();
|
||||
@endphp
|
||||
@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>
|
||||
@@ -117,7 +188,7 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Undokumentierte Ereignisse --}}
|
||||
{{-- 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">
|
||||
@@ -128,14 +199,14 @@
|
||||
</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">
|
||||
<div class="flex items-center space-x-3">
|
||||
<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>
|
||||
<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">
|
||||
@@ -143,10 +214,23 @@
|
||||
</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>
|
||||
<a href="{{ route('network.device', $event->device) }}"
|
||||
class="text-xs text-indigo-600 hover:underline">Detail →</a>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -26,11 +26,17 @@
|
||||
@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">
|
||||
{{ $devices->total() }} Ergebnis(se) für „{{ $q }}" über alle Segmente
|
||||
{{ $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>
|
||||
@@ -44,20 +50,23 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@forelse($devices as $device)
|
||||
@foreach($devices as $device)
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<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 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->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>
|
||||
@@ -69,20 +78,70 @@
|
||||
class="text-xs text-indigo-600 hover:underline">Detail →</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-10 text-center text-gray-500">Keine Geräte gefunden.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
@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>
|
||||
|
||||
@@ -22,9 +22,16 @@
|
||||
|
||||
<div>
|
||||
<x-input-label for="subnet" value="Subnetz *" />
|
||||
<x-text-input id="subnet" name="subnet" type="text" class="mt-1 block w-full font-mono"
|
||||
value="{{ old('subnet') }}" placeholder="z.B. 192.168.1.0/24 oder 10.0.0.0/8" required />
|
||||
<div class="flex gap-2 mt-1">
|
||||
<x-text-input id="subnet" name="subnet" type="text" class="block w-full font-mono"
|
||||
value="{{ old('subnet') }}" placeholder="z.B. 192.168.1.0/24 oder 10.0.0.0/8" required />
|
||||
<button type="button" id="detect-subnet-btn"
|
||||
class="shrink-0 px-3 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
🔍 Erkennen
|
||||
</button>
|
||||
</div>
|
||||
<x-input-error :messages="$errors->get('subnet')" class="mt-1" />
|
||||
<div id="subnet-suggestions" class="mt-1 hidden"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -41,6 +48,29 @@
|
||||
placeholder="Kurze Beschreibung dieses Segments...">{{ old('description') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="scan_interval_minutes" value="Scan-Zyklus (automatisch)" />
|
||||
<select id="scan_interval_minutes" name="scan_interval_minutes"
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">Manuell (kein Auto-Scan)</option>
|
||||
<option value="5" {{ old('scan_interval_minutes') == '5' ? 'selected' : '' }}>Alle 5 Minuten</option>
|
||||
<option value="15" {{ old('scan_interval_minutes') == '15' ? 'selected' : '' }}>Alle 15 Minuten</option>
|
||||
<option value="30" {{ old('scan_interval_minutes') == '30' ? 'selected' : '' }}>Alle 30 Minuten</option>
|
||||
<option value="60" {{ old('scan_interval_minutes') == '60' ? 'selected' : '' }}>Stündlich</option>
|
||||
<option value="360" {{ old('scan_interval_minutes') == '360' ? 'selected' : '' }}>Alle 6 Stunden</option>
|
||||
<option value="720" {{ old('scan_interval_minutes') == '720' ? 'selected' : '' }}>Alle 12 Stunden</option>
|
||||
<option value="1440" {{ old('scan_interval_minutes') == '1440' ? 'selected' : '' }}>Täglich</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500">Erfordert aktiven Laravel Scheduler (Cron).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="nmap_options" value="nmap-Parameter" />
|
||||
<x-text-input id="nmap_options" name="nmap_options" type="text" class="mt-1 block w-full font-mono"
|
||||
value="{{ old('nmap_options', '-sn') }}" placeholder="-sn" />
|
||||
<p class="mt-1 text-xs text-gray-500">-sn = Ping-Scan (schnell) · -sn -p 22,80,443 = mit Ports</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="active" name="active" value="1"
|
||||
{{ old('active', '1') ? 'checked' : '' }}
|
||||
@@ -62,4 +92,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('detect-subnet-btn').addEventListener('click', function() {
|
||||
const btn = this;
|
||||
const suggestions = document.getElementById('subnet-suggestions');
|
||||
btn.textContent = '⏳ Suche...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('{{ route('network.detect-subnets') }}', {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(subnets) {
|
||||
btn.textContent = '🔍 Erkennen';
|
||||
btn.disabled = false;
|
||||
if (!subnets.length) {
|
||||
suggestions.innerHTML = '<p class="text-xs text-gray-400">Keine Daten in der Datenbank gefunden.</p>';
|
||||
suggestions.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
suggestions.innerHTML = '<p class="text-xs text-gray-500 mb-1">Erkannte Subnetze – klicken zum Übernehmen:</p>' +
|
||||
subnets.map(function(s) {
|
||||
return '<button type="button" onclick="document.getElementById(\'subnet\').value=\'' + s + '\'" ' +
|
||||
'class="mr-1 mb-1 px-2 py-1 text-xs font-mono rounded border border-indigo-300 text-indigo-700 hover:bg-indigo-50 transition">' + s + '</button>';
|
||||
}).join('');
|
||||
suggestions.classList.remove('hidden');
|
||||
})
|
||||
.catch(function() {
|
||||
btn.textContent = '🔍 Erkennen';
|
||||
btn.disabled = false;
|
||||
suggestions.innerHTML = '<p class="text-xs text-red-500">Fehler bei der Erkennung.</p>';
|
||||
suggestions.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -22,9 +22,16 @@
|
||||
|
||||
<div>
|
||||
<x-input-label for="subnet" value="Subnetz *" />
|
||||
<x-text-input id="subnet" name="subnet" type="text" class="mt-1 block w-full font-mono"
|
||||
value="{{ old('subnet', $segment->subnet) }}" required />
|
||||
<div class="flex gap-2 mt-1">
|
||||
<x-text-input id="subnet" name="subnet" type="text" class="block w-full font-mono"
|
||||
value="{{ old('subnet', $segment->subnet) }}" required />
|
||||
<button type="button" id="detect-subnet-btn"
|
||||
class="shrink-0 px-3 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
🔍 Erkennen
|
||||
</button>
|
||||
</div>
|
||||
<x-input-error :messages="$errors->get('subnet')" class="mt-1" />
|
||||
<div id="subnet-suggestions" class="mt-1 hidden"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -40,6 +47,26 @@
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">{{ old('description', $segment->description) }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="scan_interval_minutes" value="Scan-Zyklus (automatisch)" />
|
||||
<select id="scan_interval_minutes" name="scan_interval_minutes"
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">Manuell (kein Auto-Scan)</option>
|
||||
@foreach([5 => 'Alle 5 Minuten', 15 => 'Alle 15 Minuten', 30 => 'Alle 30 Minuten', 60 => 'Stündlich', 360 => 'Alle 6 Stunden', 720 => 'Alle 12 Stunden', 1440 => 'Täglich'] as $val => $label)
|
||||
<option value="{{ $val }}" {{ old('scan_interval_minutes', $segment->scan_interval_minutes) == $val ? 'selected' : '' }}>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="nmap_options" value="nmap-Parameter" />
|
||||
<x-text-input id="nmap_options" name="nmap_options" type="text" class="mt-1 block w-full font-mono"
|
||||
value="{{ old('nmap_options', $segment->nmap_options) }}" placeholder="-sn" />
|
||||
<p class="mt-1 text-xs text-gray-500">-sn = Ping-Scan · -sn -p 22,80,443 = mit Ports</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="active" name="active" value="1"
|
||||
{{ old('active', $segment->active) ? 'checked' : '' }}
|
||||
@@ -61,4 +88,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('detect-subnet-btn').addEventListener('click', function() {
|
||||
const btn = this;
|
||||
const suggestions = document.getElementById('subnet-suggestions');
|
||||
btn.textContent = '⏳ Suche...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('{{ route('network.detect-subnets') }}', {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(subnets) {
|
||||
btn.textContent = '🔍 Erkennen';
|
||||
btn.disabled = false;
|
||||
if (!subnets.length) {
|
||||
suggestions.innerHTML = '<p class="text-xs text-gray-400">Keine Daten in der Datenbank gefunden.</p>';
|
||||
suggestions.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
suggestions.innerHTML = '<p class="text-xs text-gray-500 mb-1">Erkannte Subnetze – klicken zum Übernehmen:</p>' +
|
||||
subnets.map(function(s) {
|
||||
return '<button type="button" onclick="document.getElementById(\'subnet\').value=\'' + s + '\'" ' +
|
||||
'class="mr-1 mb-1 px-2 py-1 text-xs font-mono rounded border border-indigo-300 text-indigo-700 hover:bg-indigo-50 transition">' + s + '</button>';
|
||||
}).join('');
|
||||
suggestions.classList.remove('hidden');
|
||||
})
|
||||
.catch(function() {
|
||||
btn.textContent = '🔍 Erkennen';
|
||||
btn.disabled = false;
|
||||
suggestions.innerHTML = '<p class="text-xs text-red-500">Fehler bei der Erkennung.</p>';
|
||||
suggestions.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Segment {{ $segment->name }} – Export</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
color: #1f2937;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
h1 { font-size: 18px; font-weight: bold; color: #111827; }
|
||||
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.print-btn:hover { background: #4338ca; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
thead th {
|
||||
background-color: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 5px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) td { background-color: #f9fafb; }
|
||||
tbody tr:hover td { background-color: #eff6ff; }
|
||||
|
||||
.online { color: #16a34a; font-weight: 700; }
|
||||
.offline { color: #9ca3af; }
|
||||
.mono { font-family: 'Courier New', monospace; }
|
||||
.note { color: #4b5563; font-style: italic; }
|
||||
|
||||
.footer {
|
||||
margin-top: 12px;
|
||||
font-size: 9px;
|
||||
color: #9ca3af;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Print-Stile */
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
body { padding: 0; font-size: 10px; }
|
||||
thead th { background-color: #f3f4f6 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
tbody tr:nth-child(even) td { background-color: #f9fafb !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
@page { size: A4 landscape; margin: 1.5cm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🌐 Segment: {{ $segment->name }}</h1>
|
||||
<div class="meta">
|
||||
Subnetz: <strong>{{ $segment->subnet }}</strong>
|
||||
@if($segment->vlan_id) · VLAN {{ $segment->vlan_id }} @endif
|
||||
· {{ $hosts->count() }} Hosts
|
||||
· {{ $hosts->where('status', 'online')->count() }} online
|
||||
<br>
|
||||
Exportiert: {{ now()->format('d.m.Y H:i') }}
|
||||
@if($segment->description) · {{ $segment->description }} @endif
|
||||
</div>
|
||||
</div>
|
||||
<button class="print-btn no-print" onclick="window.print()">🖨️ Als PDF drucken</button>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:7%">Status</th>
|
||||
<th style="width:12%">IP-Adresse</th>
|
||||
<th style="width:16%">MAC-Adresse</th>
|
||||
<th style="width:18%">Hostname</th>
|
||||
<th style="width:14%">Hersteller</th>
|
||||
<th style="width:7%">Ping</th>
|
||||
<th>Bemerkung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($hosts as $host)
|
||||
<tr>
|
||||
<td class="{{ $host->status === 'online' ? 'online' : 'offline' }}">
|
||||
{{ $host->status === 'online' ? '● Online' : '○ Offline' }}
|
||||
</td>
|
||||
<td class="mono">{{ $host->ip_address }}</td>
|
||||
<td class="mono">{{ $host->mac_address ?? '—' }}</td>
|
||||
<td>{{ $host->hostname ?? '—' }}</td>
|
||||
<td>{{ $host->mac_vendor ?? '—' }}</td>
|
||||
<td class="mono">{{ $host->ping_ms !== null ? $host->ping_ms . ' ms' : '—' }}</td>
|
||||
<td class="note">{{ $notes[$host->ip_address] ?? '' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" style="text-align:center; padding:24px; color:#9ca3af;">
|
||||
Keine Hosts vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
Network-MGMT · MMS System Service · {{ now()->format('d.m.Y H:i') }}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Automatisch Druckdialog öffnen (nur wenn direkt als PDF aufgerufen)
|
||||
if (window.location.pathname.includes('/export/pdf')) {
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(function() { window.print(); }, 300);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,14 +6,37 @@
|
||||
<span class="text-gray-400">/</span>
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">{{ $segment->name }}</h2>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
{{-- Auto-Refresh Indikator --}}
|
||||
<span id="refresh-indicator" class="text-xs text-gray-400 dark:text-gray-500 hidden">
|
||||
↻ wird aktualisiert...
|
||||
</span>
|
||||
|
||||
{{-- Jetzt scannen --}}
|
||||
@if($segment->active)
|
||||
<form method="POST" action="{{ route('network.segments.scan', $segment) }}">
|
||||
@csrf
|
||||
<button type="submit" style="background-color: var(--color-primary)"
|
||||
class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
|
||||
▶ Jetzt scannen
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<a href="{{ route('network.import') }}?segment={{ $segment->id }}"
|
||||
style="background-color: var(--color-primary)"
|
||||
class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
|
||||
+ Scan importieren
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
⬆ Import
|
||||
</a>
|
||||
<a href="{{ route('network.segments.export.xlsx', $segment) }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
📊 Excel
|
||||
</a>
|
||||
<a href="{{ route('network.segments.export.pdf', $segment) }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
📄 PDF
|
||||
</a>
|
||||
<a href="{{ route('network.segments.edit', $segment) }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 transition">
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
Bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
@@ -39,6 +62,12 @@
|
||||
{{ $segment->active ? 'Aktiv' : 'Inaktiv' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Auto-Scan</p>
|
||||
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100 text-sm">
|
||||
{{ $segment->scan_interval_label }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Scans gesamt</p>
|
||||
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100">{{ $scans->total() }}</p>
|
||||
@@ -70,6 +99,7 @@
|
||||
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Scan-Historie</h3>
|
||||
</div>
|
||||
<div data-scan-count="{{ $scans->total() }}"></div>
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
@@ -112,4 +142,33 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session('success') && str_contains(session('success'), 'gestartet'))
|
||||
{{-- Auto-Refresh nach "Jetzt scannen": alle 10s prüfen ob neuer Scan vorliegt --}}
|
||||
<script>
|
||||
(function() {
|
||||
const currentScanCount = {{ $scans->total() }};
|
||||
const indicator = document.getElementById('refresh-indicator');
|
||||
let checks = 0;
|
||||
|
||||
const interval = setInterval(function() {
|
||||
if (++checks > 18) { clearInterval(interval); return; } // max 3 Min
|
||||
if (indicator) indicator.classList.remove('hidden');
|
||||
|
||||
fetch(window.location.href, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const newCount = parseInt(doc.querySelector('[data-scan-count]')?.dataset?.scanCount || '0');
|
||||
if (newCount > currentScanCount) {
|
||||
clearInterval(interval);
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 10000);
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
</x-app-layout>
|
||||
|
||||
@@ -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();
|
||||
|
||||
+20
-1
@@ -6,6 +6,7 @@ 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 () {
|
||||
@@ -32,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
|
||||
@@ -42,12 +47,26 @@ Route::prefix('network')
|
||||
// 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)
|
||||
// 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');
|
||||
|
||||
Reference in New Issue
Block a user