Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dc9c2c42d | |||
| af2aa1eaf5 | |||
| 85118c5bcc | |||
| 9fa20af87a | |||
| 402537805d | |||
| eb57be730b |
@@ -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
|
||||
|
||||
+151
-1
@@ -9,6 +9,149 @@ Dieses Projekt verwendet [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
---
|
||||
|
||||
## [0.10.0] - 2026-07-02
|
||||
|
||||
### Added
|
||||
- Docker-Setup: `Dockerfile` (PHP 8.5 FPM + nginx + Node.js 20 + nmap + supervisor), `docker-compose.yml` (App + MariaDB 11), automatischer Startup via `entrypoint.sh` (wartet auf DB, migriert, baut Assets, startet Supervisor)
|
||||
- `docker/nginx.conf`, `docker/supervisord.conf`: nginx als Reverse-Proxy zu PHP-FPM, Laravel Scheduler als Supervisor-Job
|
||||
- `.dockerignore` für minimale Image-Größe
|
||||
- `.env.example` auf Docker-Betrieb angepasst (DB_HOST=db, MariaDB-Variablen, APP_PORT)
|
||||
- Software-Update-Funktion: Admin → Einstellungen → 🔄 Software-Update
|
||||
- `config/version.php`: zentrale Versionsverwaltung, konfigurierbare Gitea-URL
|
||||
- Artisan-Command `app:check-update`: prüft Gitea-API auf neuere Release-Tags, speichert Ergebnis 6 Stunden im Cache
|
||||
- Artisan-Command `app:install-update`: führt `git fetch`, `git checkout <tag>`, `composer install`, `npm run build`, `php artisan migrate`, Cache-Rebuild durch — mit automatischem Wartungsmodus
|
||||
- `UpdateController` (Admin): Update-Seite mit Versionsvergleich, Release-Notes, Install-Button, manueller Tag-Eingabe
|
||||
- Navigation: „Update"-Badge im Einstellungen-Dropdown wenn neue Version verfügbar (liest aus Cache, kein Live-HTTP-Request)
|
||||
- Update-Check alle 6 Stunden via Laravel Scheduler (`app:check-update`)
|
||||
- Wartungsseite `errors/maintenance.blade.php` mit Auto-Reload
|
||||
|
||||
### Setup (einmalig)
|
||||
```
|
||||
GITEA_URL=http://<IP-des-Gitea-Servers>:3000
|
||||
GITEA_REPO=admin/Network-MGMT
|
||||
```
|
||||
In `.env` eintragen, danach steht der Update-Check zur Verfügung.
|
||||
|
||||
### Docker-Deployment
|
||||
```bash
|
||||
git clone http://<gitea>:3000/admin/Network-MGMT.git
|
||||
cd Network-MGMT
|
||||
cp .env.example .env
|
||||
# .env anpassen (Passwörter, APP_URL, GITEA_URL)
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.9.0] - 2026-07-02
|
||||
|
||||
### Added
|
||||
- Geräte-Tracking ohne MAC-Adresse: nmap-Hosts aus Remote-Subnetzen (kein ARP möglich) werden jetzt als `network_devices`-Einträge erfasst (Tracking per Hostname, Fallback per IP)
|
||||
- `mac_address` in `network_devices` ist jetzt nullable (mehrere NULL-Werte durch MySQL erlaubt)
|
||||
- Dashboard: Separater Warnblock „⚠️ IP-Adressen-Wechsel erkannt" mit Anzeige alter IP → neuer IP, Gerätename, MAC, Quittier-Button inkl. Notizfeld
|
||||
- Alle anderen Ereignisse auf dem Dashboard ebenfalls mit inline Notiz quittierbar
|
||||
- Geräte-Detailseite: IP-Verlauf zeigt Hinweis wenn ein Gerät mehrere verschiedene IPs hatte, alle bekannten IPs als Tags, aktuelle IP hervorgehoben
|
||||
- Globale Suche: `netbios_name` wird jetzt mitdurchsucht
|
||||
- Globale Suche: Zeigt auch Treffer direkt aus `network_hosts` (Scan-Verlauf) wenn noch kein Geräteeintrag vorhanden
|
||||
- Artisan-Command `network:backfill-devices`: Erstellt `network_devices`-Einträge für alle bestehenden `network_hosts` ohne `device_id`
|
||||
|
||||
### Setup (einmalig)
|
||||
```
|
||||
php artisan migrate
|
||||
php artisan network:backfill-devices
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.8.0] - 2026-07-02
|
||||
|
||||
### Added
|
||||
- IP-Bemerkungen: Pro IP-Adresse je Segment eine Bemerkung hinterlegen (inline editierbar in der Scan-Detailansicht)
|
||||
- Neue Tabelle `network_ip_notes` (Segment + IP als Unique-Key)
|
||||
- Subnetz-Erkennung: Schaltfläche „🔍 Erkennen" in Segment anlegen/bearbeiten – ermittelt /24-Subnetze aus vorhandenen IP-Daten per API (`/network/detect-subnets`)
|
||||
- Export Segment als Excel (`.xlsx`) via PhpSpreadsheet – enthält alle Hosts des letzten Scans inkl. Bemerkungen
|
||||
- Export Segment als PDF via DomPDF – Querformat-Tabelle mit Hosts, Status, MAC, Hostname, Hersteller, Ping und Bemerkungen
|
||||
- Export-Buttons (📊 Excel / 📄 PDF) auf Segment-Detailseite und Scan-Detailseite
|
||||
|
||||
### Setup (einmalig ausführen)
|
||||
```
|
||||
composer require phpoffice/phpspreadsheet barryvdh/laravel-dompdf
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.7.0] - 2026-07-02
|
||||
|
||||
### Added
|
||||
- Automatischer Netzwerkscan via `nmap` pro Segment (Artisan Command `network:scan`)
|
||||
- Scan-Zyklus pro Segment einstellbar: 5 / 15 / 30 / 60 / 360 / 720 / 1440 Minuten
|
||||
- „Jetzt scannen"-Button direkt auf der Segment-Detailseite
|
||||
- nmap-Parameter pro Segment konfigurierbar (z.B. `-sn`, `-sn -p 22,80,443`)
|
||||
- Laravel Scheduler führt `network:scan` jede Minute aus und prüft fällige Segmente
|
||||
- Auto-Refresh auf Segment-Detailseite nach manuellem Scan (prüft alle 10s)
|
||||
- Chronologischer IP-Verlauf (`/network/history`) über alle Scans und Segmente
|
||||
- Filter im IP-Verlauf: IP, MAC, Hostname, Segment, Status, Datumsbereich
|
||||
- IP-Verlauf Auto-Refresh alle 60 Sekunden (ohne aktiven Filter)
|
||||
- Navigation: neuer Menüpunkt „IP-Verlauf"
|
||||
- `last_scanned_at` und `scan_interval_minutes` in Segment-Stammdaten
|
||||
|
||||
### Setup (Cron für Scheduler)
|
||||
```
|
||||
* * * * * cd /home/arudolph/Projekte/PHP/Network-MGMT && php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.6.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
- Netzwerk-Segmentverwaltung: Subnetze mit Name, VLAN-ID und Aktiv/Inaktiv-Flag definieren
|
||||
- Neue Tabelle `network_segments` als organisatorische Einheit für Scan-Durchläufe
|
||||
- Dashboard-Ansicht „Netzwerk" mit Übersicht aller Segmente, KPI-Karten und offenen Ereignissen
|
||||
- Globale Suche über alle Segmente nach IP, MAC, Hostname und Bezeichnung
|
||||
- Navigation „Netzwerk" als Dropdown: Dashboard, Segmente, Alle Geräte, Suche, Import
|
||||
- Import-Seite: Segment-Auswahl beim Upload eines Angry IP Scanner Exports
|
||||
- Segment-Detailseite mit Scan-Historie
|
||||
- `segment_id` FK in `network_scans` Tabelle
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] - 2026-06-29
|
||||
|
||||
### Added
|
||||
- Netzwerk-Modul: Menüpunkt „Netzwerk" auf Ebene 0 für alle eingeloggten Benutzer
|
||||
- Import von Angry IP Scanner `.txt`-Exporten (tab-getrennt) via Datei-Upload
|
||||
- Automatische Erkennung und Speicherung von Netzwerkgeräten anhand MAC-Adresse
|
||||
- Chronologische Scan-Sessions mit Metadaten (Subnetz, Quelle, Gesamt-/Online-Hosts)
|
||||
- Änderungserkennung: neue Geräte, IP-Wechsel, Online/Offline-Statuswechsel
|
||||
- Ereignis-Protokoll pro Gerät mit Bestätigungs-Workflow (✓ Bestätigen)
|
||||
- Geräte-Detailansicht: Stammdaten, Bezeichnung, Notizen, IP-Verlauf, Ereignislog
|
||||
- Geräte-Übersicht mit Suche und Statusfilter (Online/Offline)
|
||||
- Scan-Detailansicht mit vollständiger Host-Tabelle
|
||||
- Manuelle Notizen zu Geräten hinzufügbar
|
||||
- 4 neue Datenbanktabellen: `network_scans`, `network_devices`, `network_hosts`, `network_device_events`
|
||||
- `NetworkScanImporter`-Service für Parser-Logik (MAC-Normalisierung, Spalten-Aliase)
|
||||
- `NetworkController` mit 9 Routen
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0] - 2026-06-29
|
||||
|
||||
### Added
|
||||
- Einstellungen → Layout: Seitenname, Logo-Upload, Button-Farbe (Colorpicker), Dark/Light-Mode
|
||||
- Settings-Tabelle als Key-Value-Store in der Datenbank
|
||||
- SettingsService mit Cache-Layer (automatische Invalidierung bei Änderung)
|
||||
- SettingsServiceProvider: Einstellungen werden global in alle Views injiziert
|
||||
- Dark-Mode via `dark`-Klasse auf HTML-Element (Tailwind CSS)
|
||||
- CSS-Variable `--color-primary` für dynamische Button-Farbe
|
||||
- Hilfe-Menü auf Ebene 0 (Dropdown) für alle eingeloggten Benutzer
|
||||
- Hilfe → Handbuch: Übersicht über Rollen, Funktionen, Bedienung
|
||||
- Hilfe → Changelog: Changelog direkt im Browser lesbar
|
||||
- Navigation: Einstellungen-Dropdown um Layout erweitert
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] - 2026-06-27
|
||||
|
||||
### Added
|
||||
@@ -46,7 +189,14 @@ 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.3.0...HEAD
|
||||
[Unreleased]: http://localhost:3000/admin/Network-MGMT/compare/v0.10.0...HEAD
|
||||
[0.10.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.9.0...v0.10.0
|
||||
[0.9.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.8.0...v0.9.0
|
||||
[0.8.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.7.0...v0.8.0
|
||||
[0.7.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.6.0...v0.7.0
|
||||
[0.6.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.5.0...v0.6.0
|
||||
[0.5.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.2.0...v0.3.0
|
||||
[0.2.0]: http://localhost:3000/admin/Network-MGMT/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: http://localhost:3000/admin/Network-MGMT/releases/tag/v0.1.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,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LayoutController extends Controller
|
||||
{
|
||||
public function __construct(private SettingsService $settings) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
return view('admin.layout.index', [
|
||||
'settings' => $this->settings->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'site_name' => ['required', 'string', 'max:100'],
|
||||
'button_color' => ['required', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||
'theme_mode' => ['required', 'in:light,dark'],
|
||||
'site_logo' => ['nullable', 'image', 'max:2048'],
|
||||
]);
|
||||
|
||||
// Logo-Upload verarbeiten
|
||||
if ($request->hasFile('site_logo')) {
|
||||
$path = $request->file('site_logo')->store('logos', 'public');
|
||||
$this->settings->set('site_logo', $path);
|
||||
}
|
||||
|
||||
$this->settings->setMany([
|
||||
'site_name' => $validated['site_name'],
|
||||
'button_color' => $validated['button_color'],
|
||||
'theme_mode' => $validated['theme_mode'],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.layout.index')
|
||||
->with('success', 'Layout-Einstellungen gespeichert.');
|
||||
}
|
||||
|
||||
public function removeLogo(): RedirectResponse
|
||||
{
|
||||
$this->settings->set('site_logo', '');
|
||||
|
||||
return redirect()
|
||||
->route('admin.layout.index')
|
||||
->with('success', 'Logo entfernt.');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ class UserController extends Controller
|
||||
|
||||
return redirect()
|
||||
->route('admin.users.index')
|
||||
->with('success', "Benutzer „{$user->name}" wurde angelegt.");
|
||||
->with('success', "Benutzer \"{$user->name}\" wurde angelegt.");
|
||||
}
|
||||
|
||||
public function edit(User $user): View
|
||||
@@ -79,7 +79,7 @@ class UserController extends Controller
|
||||
|
||||
return redirect()
|
||||
->route('admin.users.index')
|
||||
->with('success', "Benutzer „{$user->name}" wurde aktualisiert.");
|
||||
->with('success', "Benutzer \"{$user->name}\" wurde aktualisiert.");
|
||||
}
|
||||
|
||||
public function destroy(User $user): RedirectResponse
|
||||
@@ -95,6 +95,6 @@ class UserController extends Controller
|
||||
|
||||
return redirect()
|
||||
->route('admin.users.index')
|
||||
->with('success', "Benutzer „{$name}" wurde gelöscht.");
|
||||
->with('success', "Benutzer \"{$name}\" wurde gelöscht.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HelpController extends Controller
|
||||
{
|
||||
public function manual(): View
|
||||
{
|
||||
return view('help.manual');
|
||||
}
|
||||
|
||||
public function changelog(): View
|
||||
{
|
||||
$changelogPath = base_path('CHANGELOG.md');
|
||||
$content = file_exists($changelogPath)
|
||||
? file_get_contents($changelogPath)
|
||||
: 'Kein Changelog gefunden.';
|
||||
|
||||
return view('help.changelog', compact('content'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
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();
|
||||
|
||||
// IP-Wechsel separat → prominenter Block oben
|
||||
$ipChangeEvents = NetworkDeviceEvent::with('device')
|
||||
->where('documented', false)
|
||||
->where('event_type', 'ip_changed')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Alle anderen undokumentierten Ereignisse
|
||||
$recentEvents = NetworkDeviceEvent::with('device')
|
||||
->where('documented', false)
|
||||
->where('event_type', '!=', 'ip_changed')
|
||||
->latest()
|
||||
->take(15)
|
||||
->get();
|
||||
|
||||
// Letzten Scan pro Segment
|
||||
$latestScans = NetworkScan::select('network_scans.*')
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->groupBy('segment_id');
|
||||
|
||||
return view('network.dashboard', compact(
|
||||
'segments', 'totalDevices', 'onlineDevices',
|
||||
'recentEvents', 'ipChangeEvents', 'latestScans'
|
||||
));
|
||||
}
|
||||
|
||||
// --- Chronologischer IP-Verlauf ---
|
||||
public function history(Request $request): View
|
||||
{
|
||||
$query = \App\Models\NetworkHost::query()
|
||||
->join('network_scans', 'network_scans.id', '=', 'network_hosts.scan_id')
|
||||
->leftJoin('network_segments', 'network_segments.id', '=', 'network_scans.segment_id')
|
||||
->leftJoin('network_devices', 'network_devices.id', '=', 'network_hosts.device_id')
|
||||
->select([
|
||||
'network_hosts.*',
|
||||
'network_scans.created_at as scan_time',
|
||||
'network_scans.segment_id',
|
||||
'network_scans.scanner',
|
||||
'network_segments.name as segment_name',
|
||||
'network_devices.label as device_label',
|
||||
]);
|
||||
|
||||
if ($request->filled('ip')) {
|
||||
$query->where('network_hosts.ip_address', 'like', '%' . $request->ip . '%');
|
||||
}
|
||||
if ($request->filled('mac')) {
|
||||
$query->where('network_hosts.mac_address', 'like', '%' . $request->mac . '%');
|
||||
}
|
||||
if ($request->filled('hostname')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('network_hosts.hostname', 'like', '%' . $request->hostname . '%')
|
||||
->orWhere('network_devices.label', 'like', '%' . $request->hostname . '%');
|
||||
});
|
||||
}
|
||||
if ($request->filled('segment')) {
|
||||
$query->where('network_scans.segment_id', $request->segment);
|
||||
}
|
||||
if ($request->filled('status')) {
|
||||
$query->where('network_hosts.status', $request->status);
|
||||
}
|
||||
if ($request->filled('from')) {
|
||||
$query->where('network_scans.created_at', '>=', $request->from . ' 00:00:00');
|
||||
}
|
||||
if ($request->filled('to')) {
|
||||
$query->where('network_scans.created_at', '<=', $request->to . ' 23:59:59');
|
||||
}
|
||||
|
||||
$entries = $query->orderByDesc('network_scans.created_at')
|
||||
->orderByRaw('INET_ATON(network_hosts.ip_address)')
|
||||
->paginate(100)
|
||||
->withQueryString();
|
||||
|
||||
$segments = \App\Models\NetworkSegment::orderBy('name')->get();
|
||||
|
||||
return view('network.history', compact('entries', 'segments'));
|
||||
}
|
||||
|
||||
// --- Globale Suche ---
|
||||
public function search(Request $request): View
|
||||
{
|
||||
$q = trim($request->get('q', ''));
|
||||
$devices = collect();
|
||||
$hostResults = collect();
|
||||
|
||||
if (strlen($q) >= 2) {
|
||||
// Suche in network_devices (MAC-basiert / bereits getracked)
|
||||
$devices = NetworkDevice::with('events')
|
||||
->where(function ($query) use ($q) {
|
||||
$query->where('current_ip', 'like', "%{$q}%")
|
||||
->orWhere('mac_address', 'like', "%{$q}%")
|
||||
->orWhere('hostname', 'like', "%{$q}%")
|
||||
->orWhere('netbios_name','like', "%{$q}%")
|
||||
->orWhere('label', 'like', "%{$q}%")
|
||||
->orWhere('mac_vendor', 'like', "%{$q}%");
|
||||
})
|
||||
->orderBy('current_ip')
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
|
||||
// Zusätzlich: direkte Suche in network_hosts (auch ohne Device-Eintrag)
|
||||
// Zeigt IPs/Hosts die in Scans gefunden wurden, aber noch kein Device-Record haben
|
||||
$hostResults = NetworkHost::query()
|
||||
->join('network_scans', 'network_scans.id', '=', 'network_hosts.scan_id')
|
||||
->leftJoin('network_segments', 'network_segments.id', '=', 'network_scans.segment_id')
|
||||
->whereNull('network_hosts.device_id')
|
||||
->where(function ($query) use ($q) {
|
||||
$query->where('network_hosts.ip_address', 'like', "%{$q}%")
|
||||
->orWhere('network_hosts.mac_address', 'like', "%{$q}%")
|
||||
->orWhere('network_hosts.hostname', 'like', "%{$q}%");
|
||||
})
|
||||
->select([
|
||||
'network_hosts.ip_address',
|
||||
'network_hosts.mac_address',
|
||||
'network_hosts.hostname',
|
||||
'network_hosts.mac_vendor',
|
||||
'network_hosts.status',
|
||||
'network_hosts.ping_ms',
|
||||
'network_scans.created_at as scan_time',
|
||||
'network_segments.name as segment_name',
|
||||
DB::raw('network_scans.id as scan_id'),
|
||||
])
|
||||
->orderByDesc('network_scans.created_at')
|
||||
->limit(50)
|
||||
->get()
|
||||
->unique('ip_address'); // Jede IP nur einmal (neuester Fund)
|
||||
}
|
||||
|
||||
return view('network.search', compact('q', 'devices', 'hostResults'));
|
||||
}
|
||||
|
||||
// --- Alte index-Route umleiten ---
|
||||
public function index(): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
return redirect()->route('network.dashboard');
|
||||
}
|
||||
|
||||
// --- Alle Geräte ---
|
||||
public function devices(Request $request): View
|
||||
{
|
||||
$query = NetworkDevice::with('events')
|
||||
->orderBy('current_ip');
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
if ($request->filled('search')) {
|
||||
$s = $request->search;
|
||||
$query->where(function ($q) use ($s) {
|
||||
$q->where('current_ip', 'like', "%{$s}%")
|
||||
->orWhere('mac_address', 'like', "%{$s}%")
|
||||
->orWhere('hostname', 'like', "%{$s}%")
|
||||
->orWhere('label', 'like', "%{$s}%")
|
||||
->orWhere('mac_vendor', 'like', "%{$s}%");
|
||||
});
|
||||
}
|
||||
|
||||
$devices = $query->paginate(50)->withQueryString();
|
||||
|
||||
return view('network.devices', compact('devices'));
|
||||
}
|
||||
|
||||
// --- Geräte-Detail ---
|
||||
public function device(NetworkDevice $device): View
|
||||
{
|
||||
$device->load(['events.documentedBy', 'hosts.scan']);
|
||||
$ipHistory = $device->hosts()
|
||||
->select('ip_address', 'status', 'ping_ms', 'created_at')
|
||||
->join('network_scans', 'network_scans.id', '=', 'network_hosts.scan_id')
|
||||
->orderByDesc('network_hosts.created_at')
|
||||
->take(50)
|
||||
->get();
|
||||
|
||||
return view('network.device', compact('device', 'ipHistory'));
|
||||
}
|
||||
|
||||
// --- Gerät: Label/Notiz speichern ---
|
||||
public function updateDevice(Request $request, NetworkDevice $device): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'label' => ['nullable', 'string', 'max:100'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$oldLabel = $device->label;
|
||||
$device->update($validated);
|
||||
|
||||
if ($oldLabel !== $validated['label']) {
|
||||
NetworkDeviceEvent::create([
|
||||
'device_id' => $device->id,
|
||||
'event_type' => 'label_changed',
|
||||
'old_value' => $oldLabel,
|
||||
'new_value' => $validated['label'],
|
||||
'description'=> 'Bezeichnung manuell geändert',
|
||||
'documented' => true,
|
||||
'documented_by' => auth()->id(),
|
||||
'documented_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('network.device', $device)
|
||||
->with('success', 'Gerät aktualisiert.');
|
||||
}
|
||||
|
||||
// --- Ereignis dokumentieren ---
|
||||
public function documentEvent(Request $request, NetworkDeviceEvent $event): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$event->update([
|
||||
'documented' => true,
|
||||
'documented_by' => auth()->id(),
|
||||
'documented_at' => now(),
|
||||
'description' => $request->description ?? $event->description,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Ereignis dokumentiert.');
|
||||
}
|
||||
|
||||
// --- Manuelle Notiz hinzufügen ---
|
||||
public function addNote(Request $request, NetworkDevice $device): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'description' => ['required', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
NetworkDeviceEvent::create([
|
||||
'device_id' => $device->id,
|
||||
'event_type' => 'manual_note',
|
||||
'description' => $request->description,
|
||||
'documented' => true,
|
||||
'documented_by' => auth()->id(),
|
||||
'documented_at' => now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Notiz hinzugefügt.');
|
||||
}
|
||||
|
||||
// --- Import ---
|
||||
public function showImport(): View
|
||||
{
|
||||
$segments = NetworkSegment::where('active', true)->orderBy('name')->get();
|
||||
return view('network.import', compact('segments'));
|
||||
}
|
||||
|
||||
public function import(Request $request, NetworkScanImporter $importer): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'segment_id' => ['nullable', 'exists:network_segments,id'],
|
||||
'scan_file' => ['required', 'file', 'mimes:txt,csv', 'max:10240'],
|
||||
]);
|
||||
|
||||
$path = $request->file('scan_file')->store('imports', 'local');
|
||||
$fullPath = Storage::disk('local')->path($path);
|
||||
|
||||
$scan = $importer->importAngryIpScannerFile(
|
||||
$fullPath,
|
||||
auth()->id(),
|
||||
$request->input('segment_id')
|
||||
);
|
||||
|
||||
return redirect()->route('network.scan', $scan)
|
||||
->with('success', "Import abgeschlossen: {$scan->online_hosts} online, {$scan->new_devices} neue Geräte.");
|
||||
}
|
||||
|
||||
// --- Scan-Detail ---
|
||||
public function scan(NetworkScan $scan): View
|
||||
{
|
||||
$hosts = $scan->hosts()
|
||||
->orderByRaw("INET_ATON(ip_address)")
|
||||
->get();
|
||||
|
||||
$notes = collect();
|
||||
if ($scan->segment_id) {
|
||||
$notes = \App\Models\NetworkIpNote::where('segment_id', $scan->segment_id)
|
||||
->pluck('note', 'ip_address');
|
||||
}
|
||||
|
||||
return view('network.scan', compact('scan', 'hosts', 'notes'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\NetworkIpNote;
|
||||
use App\Models\NetworkSegment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NetworkSegmentController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$segments = NetworkSegment::withCount('scans')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('network.segments.index', compact('segments'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('network.segments.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'subnet' => ['required', 'string', 'max:50'],
|
||||
'vlan_id' => ['nullable', 'integer', 'min:1', 'max:4094'],
|
||||
'active' => ['boolean'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$validated['active'] = $request->boolean('active', true);
|
||||
$validated['created_by'] = auth()->id();
|
||||
|
||||
NetworkSegment::create($validated);
|
||||
|
||||
return redirect()->route('network.segments.index')
|
||||
->with('success', "Segment \"{$validated['name']}\" angelegt.");
|
||||
}
|
||||
|
||||
public function show(NetworkSegment $segment): View
|
||||
{
|
||||
$scans = $segment->scans()->latest()->paginate(20);
|
||||
$latestScan = $segment->scans()->latest()->first();
|
||||
|
||||
return view('network.segments.show', compact('segment', 'scans', 'latestScan'));
|
||||
}
|
||||
|
||||
public function edit(NetworkSegment $segment): View
|
||||
{
|
||||
return view('network.segments.edit', compact('segment'));
|
||||
}
|
||||
|
||||
public function update(Request $request, NetworkSegment $segment): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'subnet' => ['required', 'string', 'max:50'],
|
||||
'vlan_id' => ['nullable', 'integer', 'min:1', 'max:4094'],
|
||||
'active' => ['boolean'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$validated['active'] = $request->boolean('active', true);
|
||||
$segment->update($validated);
|
||||
|
||||
return redirect()->route('network.segments.index')
|
||||
->with('success', "Segment \"{$segment->name}\" aktualisiert.");
|
||||
}
|
||||
|
||||
public function destroy(NetworkSegment $segment): RedirectResponse
|
||||
{
|
||||
$name = $segment->name;
|
||||
$segment->delete();
|
||||
|
||||
return redirect()->route('network.segments.index')
|
||||
->with('success', "Segment \"{$name}\" gelöscht.");
|
||||
}
|
||||
|
||||
public function triggerScan(NetworkSegment $segment): RedirectResponse
|
||||
{
|
||||
if (!$segment->active) {
|
||||
return back()->with('error', 'Segment ist inaktiv.');
|
||||
}
|
||||
|
||||
// Scan im Hintergrund starten (non-blocking)
|
||||
$projectPath = base_path();
|
||||
$phpBinary = PHP_BINARY;
|
||||
$cmd = "cd {$projectPath} && {$phpBinary} artisan network:scan {$segment->id} --force > /dev/null 2>&1 &";
|
||||
exec($cmd);
|
||||
|
||||
return redirect()->route('network.segments.show', $segment)
|
||||
->with('success', "Scan für \"{$segment->name}\" wurde gestartet. Ergebnisse erscheinen in Kürze.");
|
||||
}
|
||||
|
||||
// --- IP-Notiz speichern (Upsert per Segment + IP) ---
|
||||
public function saveIpNote(Request $request, NetworkSegment $segment): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'ip_address' => ['required', 'ip'],
|
||||
'note' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
NetworkIpNote::updateOrCreate(
|
||||
['segment_id' => $segment->id, 'ip_address' => $request->ip_address],
|
||||
[
|
||||
'note' => $request->note,
|
||||
'updated_by' => auth()->id(),
|
||||
'created_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// --- Export: Excel (OpenSpout – kein ext-gd, PHP 8.5 kompatibel) ---
|
||||
public function exportXlsx(NetworkSegment $segment)
|
||||
{
|
||||
$hosts = $this->getLatestScanHosts($segment);
|
||||
$notes = NetworkIpNote::where('segment_id', $segment->id)
|
||||
->pluck('note', 'ip_address');
|
||||
|
||||
$filename = 'Segment_' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $segment->name)
|
||||
. '_' . now()->format('Ymd_Hi') . '.xlsx';
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'net_export_') . '.xlsx';
|
||||
|
||||
$writer = new \OpenSpout\Writer\XLSX\Writer();
|
||||
$writer->openToFile($tempFile);
|
||||
|
||||
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues([
|
||||
'IP-Adresse', 'Status', 'Hostname', 'MAC-Adresse', 'Hersteller', 'Ping (ms)', 'Bemerkung',
|
||||
]));
|
||||
|
||||
foreach ($hosts as $host) {
|
||||
$writer->addRow(\OpenSpout\Common\Entity\Row::fromValues([
|
||||
$host->ip_address,
|
||||
$host->status,
|
||||
$host->hostname ?? '',
|
||||
$host->mac_address ?? '',
|
||||
$host->mac_vendor ?? '',
|
||||
$host->ping_ms !== null ? (string) $host->ping_ms : '',
|
||||
$notes[$host->ip_address] ?? '',
|
||||
]));
|
||||
}
|
||||
|
||||
$writer->close();
|
||||
|
||||
return response()->download($tempFile, $filename, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
])->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
// --- Export: PDF (Browser-Print-View – kein PHP-PDF-Package nötig) ---
|
||||
public function exportPdf(NetworkSegment $segment)
|
||||
{
|
||||
$hosts = $this->getLatestScanHosts($segment);
|
||||
$notes = NetworkIpNote::where('segment_id', $segment->id)
|
||||
->pluck('note', 'ip_address');
|
||||
|
||||
return view('network.segments.export-pdf', compact('segment', 'hosts', 'notes'));
|
||||
}
|
||||
|
||||
private function getLatestScanHosts(NetworkSegment $segment)
|
||||
{
|
||||
$latestScan = $segment->scans()->latest()->first();
|
||||
if (!$latestScan) {
|
||||
return collect();
|
||||
}
|
||||
return $latestScan->hosts()->orderByRaw('INET_ATON(ip_address)')->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class NetworkDevice extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'mac_address', 'current_ip', 'hostname', 'mac_vendor',
|
||||
'status', 'netbios_name', 'ttl', 'ports', 'notes',
|
||||
'label', 'first_seen_at', 'last_seen_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'first_seen_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function hosts(): HasMany
|
||||
{
|
||||
return $this->hasMany(NetworkHost::class, 'device_id');
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(NetworkDeviceEvent::class, 'device_id')->latest();
|
||||
}
|
||||
|
||||
public function getDisplayNameAttribute(): string
|
||||
{
|
||||
return $this->label
|
||||
?? $this->hostname
|
||||
?? $this->current_ip
|
||||
?? $this->mac_address;
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match($this->status) {
|
||||
'online' => 'green',
|
||||
'offline' => 'red',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class NetworkDeviceEvent extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'scan_id', 'event_type', 'old_value',
|
||||
'new_value', 'description', 'documented',
|
||||
'documented_by', 'documented_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'documented' => 'boolean',
|
||||
'documented_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NetworkDevice::class, 'device_id');
|
||||
}
|
||||
|
||||
public function documentedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'documented_by');
|
||||
}
|
||||
|
||||
public function getEventLabelAttribute(): string
|
||||
{
|
||||
return match($this->event_type) {
|
||||
'new_device' => 'Neues Gerät',
|
||||
'ip_changed' => 'IP-Adresse geändert',
|
||||
'came_online' => 'Gerät online',
|
||||
'went_offline' => 'Gerät offline',
|
||||
'manual_note' => 'Manuelle Notiz',
|
||||
'label_changed' => 'Bezeichnung geändert',
|
||||
default => $this->event_type,
|
||||
};
|
||||
}
|
||||
|
||||
public function getEventColorAttribute(): string
|
||||
{
|
||||
return match($this->event_type) {
|
||||
'new_device' => 'blue',
|
||||
'ip_changed' => 'yellow',
|
||||
'came_online' => 'green',
|
||||
'went_offline' => 'red',
|
||||
'manual_note' => 'purple',
|
||||
'label_changed' => 'gray',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class NetworkHost extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'scan_id', 'device_id', 'ip_address', 'mac_address',
|
||||
'hostname', 'mac_vendor', 'status', 'ping_ms',
|
||||
'netbios_info', 'ttl', 'ports', 'http_sender', 'web_detection',
|
||||
];
|
||||
|
||||
public function scan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NetworkScan::class, 'scan_id');
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NetworkDevice::class, 'device_id');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class NetworkScan extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'segment_id', 'subnet', 'source', 'scanner', 'total_hosts',
|
||||
'online_hosts', 'new_devices', 'changed_devices',
|
||||
'notes', 'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function hosts(): HasMany
|
||||
{
|
||||
return $this->hasMany(NetworkHost::class, 'scan_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function segment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NetworkSegment::class, 'segment_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
|
||||
class NetworkSegment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name', 'subnet', 'vlan_id', 'active', 'description',
|
||||
'scan_interval_minutes', 'last_scanned_at', 'nmap_options', 'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => 'boolean',
|
||||
'vlan_id' => 'integer',
|
||||
'scan_interval_minutes' => 'integer',
|
||||
'last_scanned_at' => 'datetime',
|
||||
];
|
||||
|
||||
/** Ist ein automatischer Scan fällig? */
|
||||
public function isScanDue(): bool
|
||||
{
|
||||
if (!$this->active || !$this->scan_interval_minutes) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->last_scanned_at) {
|
||||
return true;
|
||||
}
|
||||
return $this->last_scanned_at->addMinutes($this->scan_interval_minutes)->isPast();
|
||||
}
|
||||
|
||||
/** Lesbare Intervall-Beschreibung */
|
||||
public function getScanIntervalLabelAttribute(): string
|
||||
{
|
||||
return match($this->scan_interval_minutes) {
|
||||
5 => 'alle 5 Minuten',
|
||||
15 => 'alle 15 Minuten',
|
||||
30 => 'alle 30 Minuten',
|
||||
60 => 'stündlich',
|
||||
360 => 'alle 6 Stunden',
|
||||
720 => 'alle 12 Stunden',
|
||||
1440 => 'täglich',
|
||||
default => 'manuell',
|
||||
};
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function scans(): HasMany
|
||||
{
|
||||
return $this->hasMany(NetworkScan::class, 'segment_id');
|
||||
}
|
||||
|
||||
public function latestScan(): HasMany
|
||||
{
|
||||
return $this->hasMany(NetworkScan::class, 'segment_id')->latestOfMany();
|
||||
}
|
||||
|
||||
/** Alle Hosts dieses Segments (über Scans) */
|
||||
public function hosts(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(NetworkHost::class, NetworkScan::class, 'segment_id', 'scan_id');
|
||||
}
|
||||
|
||||
/** Anzahl online-Hosts im letzten Scan */
|
||||
public function getOnlineCountAttribute(): int
|
||||
{
|
||||
return $this->scans()->latest()->first()?->online_hosts ?? 0;
|
||||
}
|
||||
|
||||
/** Anzahl Geräte gesamt im letzten Scan */
|
||||
public function getTotalCountAttribute(): int
|
||||
{
|
||||
return $this->scans()->latest()->first()?->total_hosts ?? 0;
|
||||
}
|
||||
|
||||
/** Letzter Scan-Zeitpunkt */
|
||||
public function getLastScannedAtAttribute(): ?string
|
||||
{
|
||||
return $this->scans()->latest()->first()?->created_at?->format('d.m.Y H:i');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $fillable = ['key', 'value'];
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return static::where('key', $key)->value('value') ?? $default;
|
||||
}
|
||||
|
||||
public static function set(string $key, mixed $value): void
|
||||
{
|
||||
static::updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
}
|
||||
|
||||
public static function allAsArray(): array
|
||||
{
|
||||
return static::pluck('value', 'key')->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
class SettingsServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(SettingsService::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Settings nur laden wenn die Tabelle existiert (z.B. vor Migration schützen)
|
||||
if (!$this->app->runningInConsole() && Schema::hasTable('settings')) {
|
||||
$settings = $this->app->make(SettingsService::class)->all();
|
||||
|
||||
View::share('appSettings', $settings);
|
||||
} else {
|
||||
View::share('appSettings', [
|
||||
'site_name' => config('app.name'),
|
||||
'site_logo' => '',
|
||||
'button_color' => '#4f46e5',
|
||||
'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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\NetworkDevice;
|
||||
use App\Models\NetworkDeviceEvent;
|
||||
use App\Models\NetworkHost;
|
||||
use App\Models\NetworkScan;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NetworkScanImporter
|
||||
{
|
||||
private NetworkScan $scan;
|
||||
private int $newDevices = 0;
|
||||
private int $changedDevices = 0;
|
||||
private int $onlineHosts = 0;
|
||||
|
||||
/**
|
||||
* Importiert eine Angry IP Scanner .txt Exportdatei.
|
||||
*/
|
||||
public function importAngryIpScannerFile(string $filePath, int $createdBy, ?int $segmentId = null): NetworkScan
|
||||
{
|
||||
// Datei einlesen und UTF-8 BOM entfernen
|
||||
$content = file_get_contents($filePath);
|
||||
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content); // UTF-8 BOM
|
||||
$content = str_replace("\r\n", "\n", $content); // Windows CRLF → LF
|
||||
$content = str_replace("\r", "\n", $content); // altes Mac CR → LF
|
||||
|
||||
$lines = array_filter(explode("\n", $content), fn($l) => trim($l) !== '');
|
||||
$lines = array_values($lines);
|
||||
|
||||
$scanner = '';
|
||||
$subnet = '';
|
||||
$headers = [];
|
||||
$rows = [];
|
||||
$splitPattern = null; // wird beim ersten Header-Treffer gesetzt
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
if (str_starts_with($trimmed, 'Erstellt von') || str_starts_with($trimmed, 'Created by')) {
|
||||
$scanner = trim(str_replace(['Erstellt von ', 'Created by '], '', $trimmed));
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($trimmed, 'Gescannt') || str_starts_with($trimmed, 'Scanned')) {
|
||||
preg_match('/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/', $trimmed, $m);
|
||||
$subnet = $m[1] ?? '0.0.0.0';
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($trimmed, 'http')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Header-Zeile erkennen (beginnt mit "IP")
|
||||
if (str_starts_with($trimmed, 'IP') && empty($headers)) {
|
||||
// Trennzeichen auto-detektieren
|
||||
if (substr_count($trimmed, "\t") >= 2) {
|
||||
$splitPattern = '/\t/';
|
||||
} elseif (substr_count($trimmed, ';') >= 2) {
|
||||
$splitPattern = '/;/';
|
||||
} elseif (substr_count($trimmed, ',') >= 2) {
|
||||
$splitPattern = '/,/';
|
||||
} else {
|
||||
// Angry IP Scanner: mehrere normale Leerzeichen als Trennzeichen
|
||||
// Non-Breaking Spaces (0xC2 0xA0) bleiben innerhalb von Werten erhalten
|
||||
$splitPattern = '/ {2,}/';
|
||||
}
|
||||
$headers = array_map('trim', preg_split($splitPattern, $trimmed));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Datenzeile: muss mit IP-Adresse beginnen
|
||||
if (!empty($headers) && $splitPattern !== null
|
||||
&& preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/', $trimmed)) {
|
||||
$cols = array_map(
|
||||
fn($v) => trim(str_replace("\xc2\xa0", ' ', $v)), // Non-Breaking Spaces → normale Spaces
|
||||
preg_split($splitPattern, $line)
|
||||
);
|
||||
$row = [];
|
||||
foreach ($headers as $i => $h) {
|
||||
$row[$h] = $cols[$i] ?? '';
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
// DEBUG: Log parse results
|
||||
Log::info('NetworkScanImporter: scanner=' . $scanner . ', subnet=' . $subnet);
|
||||
Log::info('NetworkScanImporter: separator hex=' . bin2hex($separator));
|
||||
Log::info('NetworkScanImporter: headers=' . json_encode($headers));
|
||||
Log::info('NetworkScanImporter: rows found=' . count($rows));
|
||||
if (!empty($rows)) {
|
||||
Log::info('NetworkScanImporter: first row=' . json_encode($rows[0]));
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($rows, $scanner, $subnet, $createdBy, $segmentId) {
|
||||
$this->scan = NetworkScan::create([
|
||||
'segment_id' => $segmentId,
|
||||
'subnet' => $subnet,
|
||||
'source' => 'import',
|
||||
'scanner' => $scanner,
|
||||
'created_by' => $createdBy,
|
||||
]);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$this->processRow($row);
|
||||
}
|
||||
|
||||
$this->scan->update([
|
||||
'total_hosts' => count($rows),
|
||||
'online_hosts' => $this->onlineHosts,
|
||||
'new_devices' => $this->newDevices,
|
||||
'changed_devices'=> $this->changedDevices,
|
||||
]);
|
||||
|
||||
return $this->scan;
|
||||
});
|
||||
}
|
||||
|
||||
private function processRow(array $row): void
|
||||
{
|
||||
$ip = trim($this->extractColumn($row, ['IP', 'IP-Adresse', 'IP Address', 'IP Adresse']));
|
||||
$ping = trim($this->extractColumn($row, ['Ping', 'Ping (ms)']));
|
||||
$host = $this->cleanValue($this->extractColumn($row, ['Hostname', 'Host Name', 'DNS-Hostname']));
|
||||
$mac = $this->normalizeMAC($this->extractColumn($row, ['MAC Adresse', 'MAC Addresse', 'MAC Address', 'MAC-Adresse', 'MAC']));
|
||||
$vendor = $this->cleanValue($this->extractColumn($row, ['MAC Hersteller', 'MAC Vendor', 'Hersteller', 'Vendor']));
|
||||
$ttl = (int) $this->cleanValue($this->extractColumn($row, ['TTL'])) ?: null;
|
||||
$ports = $this->cleanValue($this->extractColumn($row, ['Ports', 'Gefilterte Ports', 'Offene Ports']));
|
||||
$netbios= $this->cleanValue($this->extractColumn($row, ['NetBIOS Info', 'NetBIOS-Info', 'NetBIOS']));
|
||||
$http = $this->cleanValue($this->extractColumn($row, ['HTTP Sender', 'HTTP']));
|
||||
$web = $this->cleanValue($this->extractColumn($row, ['Web Erkennung', 'Web Detection', 'Webserver']));
|
||||
|
||||
$pingMs = null;
|
||||
$status = 'offline';
|
||||
|
||||
// Online-Erkennung: Ping enthält eine Zahl gefolgt von "ms"
|
||||
if (preg_match('/(\d+)\s*ms/i', $ping, $m)) {
|
||||
$pingMs = (int) $m[1];
|
||||
$status = 'online';
|
||||
$this->onlineHosts++;
|
||||
}
|
||||
|
||||
// Host-Eintrag speichern
|
||||
$host_record = NetworkHost::create([
|
||||
'scan_id' => $this->scan->id,
|
||||
'ip_address' => $ip,
|
||||
'mac_address' => $mac ?: null,
|
||||
'hostname' => $host ?: null,
|
||||
'mac_vendor' => $vendor ?: null,
|
||||
'status' => $status,
|
||||
'ping_ms' => $pingMs,
|
||||
'netbios_info' => $netbios ?: null,
|
||||
'ttl' => $ttl,
|
||||
'ports' => $ports ?: null,
|
||||
'http_sender' => $http ?: null,
|
||||
'web_detection' => $web ?: null,
|
||||
]);
|
||||
|
||||
// Geräte-Master nur wenn MAC bekannt
|
||||
if (!empty($mac)) {
|
||||
$this->processDevice($host_record, $ip, $mac, $host, $vendor, $netbios, $ttl, $ports, $status);
|
||||
}
|
||||
}
|
||||
|
||||
private function processDevice(
|
||||
NetworkHost $hostRecord,
|
||||
string $ip, string $mac, ?string $hostname,
|
||||
?string $vendor, ?string $netbios, ?int $ttl,
|
||||
?string $ports, string $status
|
||||
): void {
|
||||
$device = NetworkDevice::firstOrNew(['mac_address' => $mac]);
|
||||
$isNew = !$device->exists;
|
||||
|
||||
if ($isNew) {
|
||||
$device->fill([
|
||||
'current_ip' => $ip,
|
||||
'hostname' => $hostname,
|
||||
'mac_vendor' => $vendor,
|
||||
'status' => $status,
|
||||
'netbios_name' => $netbios,
|
||||
'ttl' => $ttl,
|
||||
'ports' => $ports,
|
||||
'first_seen_at'=> now(),
|
||||
'last_seen_at' => now(),
|
||||
])->save();
|
||||
|
||||
NetworkDeviceEvent::create([
|
||||
'device_id' => $device->id,
|
||||
'scan_id' => $this->scan->id,
|
||||
'event_type' => 'new_device',
|
||||
'new_value' => $ip,
|
||||
'description'=> "Erstes Erscheinen: {$ip} ({$vendor})",
|
||||
]);
|
||||
$this->newDevices++;
|
||||
} else {
|
||||
$events = [];
|
||||
|
||||
// IP-Änderung erkennen
|
||||
if ($device->current_ip !== $ip) {
|
||||
$events[] = [
|
||||
'event_type' => 'ip_changed',
|
||||
'old_value' => $device->current_ip,
|
||||
'new_value' => $ip,
|
||||
'description'=> "IP geändert von {$device->current_ip} zu {$ip}",
|
||||
];
|
||||
$this->changedDevices++;
|
||||
}
|
||||
|
||||
// Online/Offline-Status
|
||||
if ($device->status !== $status) {
|
||||
$events[] = [
|
||||
'event_type' => $status === 'online' ? 'came_online' : 'went_offline',
|
||||
'old_value' => $device->status,
|
||||
'new_value' => $status,
|
||||
'description'=> $status === 'online'
|
||||
? "Gerät wieder online ({$ip})"
|
||||
: "Gerät offline ({$device->current_ip})",
|
||||
];
|
||||
}
|
||||
|
||||
$device->update([
|
||||
'current_ip' => $ip,
|
||||
'hostname' => $hostname ?? $device->hostname,
|
||||
'status' => $status,
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ($events as $event) {
|
||||
NetworkDeviceEvent::create(array_merge($event, [
|
||||
'device_id' => $device->id,
|
||||
'scan_id' => $this->scan->id,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
$hostRecord->update(['device_id' => $device->id]);
|
||||
}
|
||||
|
||||
private function extractColumn(array $row, array $keys): string
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (isset($row[$key]) && $row[$key] !== '') {
|
||||
return $row[$key];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereinigt Angry IP Scanner Platzhalterwerte wie [n/a], [n/s], [n/d] zu leerem String.
|
||||
*/
|
||||
private function cleanValue(string $value): string
|
||||
{
|
||||
if (preg_match('/^\[n\/[asd]\]$/i', trim($value))) {
|
||||
return '';
|
||||
}
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
private function normalizeMAC(string $mac): string
|
||||
{
|
||||
// Verschiedene MAC-Formate normalisieren zu XX:XX:XX:XX:XX:XX
|
||||
$clean = preg_replace('/[^a-fA-F0-9]/', '', $mac);
|
||||
if (strlen($clean) !== 12) {
|
||||
return '';
|
||||
}
|
||||
return strtoupper(implode(':', str_split($clean, 2)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SettingsService
|
||||
{
|
||||
private const CACHE_KEY = 'app_settings';
|
||||
private const CACHE_TTL = 3600;
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, fn() => Setting::allAsArray());
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->all()[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
Setting::set($key, $value);
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
}
|
||||
|
||||
public function setMany(array $data): void
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
Setting::set($key, $value);
|
||||
}
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -6,13 +6,20 @@ use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withProviders([
|
||||
App\Providers\SettingsServiceProvider::class,
|
||||
])
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
$middleware->alias([
|
||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
||||
@@ -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,23 @@
|
||||
<?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('settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key')->unique();
|
||||
$table->text('value')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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_scans', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('subnet'); // z.B. 192.168.86.0/24
|
||||
$table->string('source')->default('manual'); // manual | import | auto
|
||||
$table->string('scanner')->nullable(); // z.B. "Angry IP Scanner 3.9.3"
|
||||
$table->integer('total_hosts')->default(0);
|
||||
$table->integer('online_hosts')->default(0);
|
||||
$table->integer('new_devices')->default(0);
|
||||
$table->integer('changed_devices')->default(0);
|
||||
$table->text('notes')->nullable();
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('network_scans');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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_devices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('mac_address')->unique(); // Primärschlüssel-Logik
|
||||
$table->string('current_ip')->nullable();
|
||||
$table->string('hostname')->nullable();
|
||||
$table->string('mac_vendor')->nullable(); // Hersteller
|
||||
$table->string('status')->default('unknown'); // online | offline | unknown
|
||||
$table->string('netbios_name')->nullable();
|
||||
$table->integer('ttl')->nullable();
|
||||
$table->text('ports')->nullable();
|
||||
$table->text('notes')->nullable(); // manuelle Notizen
|
||||
$table->string('label')->nullable(); // Benutzerfreundlicher Name
|
||||
$table->timestamp('first_seen_at')->nullable();
|
||||
$table->timestamp('last_seen_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('network_devices');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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_hosts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('scan_id')->constrained('network_scans')->cascadeOnDelete();
|
||||
$table->foreignId('device_id')->nullable()->constrained('network_devices')->nullOnDelete();
|
||||
$table->string('ip_address');
|
||||
$table->string('mac_address')->nullable();
|
||||
$table->string('hostname')->nullable();
|
||||
$table->string('mac_vendor')->nullable();
|
||||
$table->string('status')->default('offline'); // online | offline | filtered
|
||||
$table->integer('ping_ms')->nullable();
|
||||
$table->string('netbios_info')->nullable();
|
||||
$table->integer('ttl')->nullable();
|
||||
$table->text('ports')->nullable();
|
||||
$table->text('http_sender')->nullable();
|
||||
$table->text('web_detection')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['scan_id', 'ip_address']);
|
||||
$table->index('mac_address');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('network_hosts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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_device_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('device_id')->constrained('network_devices')->cascadeOnDelete();
|
||||
$table->foreignId('scan_id')->nullable()->constrained('network_scans')->nullOnDelete();
|
||||
$table->string('event_type'); // new_device | ip_changed | came_online | went_offline | manual_note | label_changed
|
||||
$table->string('old_value')->nullable();
|
||||
$table->string('new_value')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('documented')->default(false); // manuell bestätigt
|
||||
$table->foreignId('documented_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('documented_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['device_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('network_device_events');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('network_segments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name'); // z.B. "Büro", "Server-VLAN"
|
||||
$table->string('subnet'); // z.B. "192.168.86.0/24"
|
||||
$table->unsignedSmallInteger('vlan_id')->nullable(); // z.B. 10, 20
|
||||
$table->boolean('active')->default(true); // aktiv überwachen
|
||||
$table->text('description')->nullable(); // optionale Beschreibung
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('network_segments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('network_scans', function (Blueprint $table) {
|
||||
$table->foreignId('segment_id')
|
||||
->nullable()
|
||||
->after('id')
|
||||
->constrained('network_segments')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('network_scans', function (Blueprint $table) {
|
||||
$table->dropForeign(['segment_id']);
|
||||
$table->dropColumn('segment_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('network_segments', function (Blueprint $table) {
|
||||
// Scan-Zyklus in Minuten: null = manuell, 5/15/30/60/360/1440
|
||||
$table->unsignedInteger('scan_interval_minutes')->nullable()->after('active');
|
||||
// Zeitpunkt des letzten automatischen Scans
|
||||
$table->timestamp('last_scanned_at')->nullable()->after('scan_interval_minutes');
|
||||
// Optionale nmap-Parameter, z.B. "-sn" oder "-sn -p 22,80,443"
|
||||
$table->string('nmap_options')->default('-sn')->after('last_scanned_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('network_segments', function (Blueprint $table) {
|
||||
$table->dropColumn(['scan_interval_minutes', 'last_scanned_at', 'nmap_options']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('network_ip_notes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('segment_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->index();
|
||||
$table->text('note')->nullable();
|
||||
$table->unsignedBigInteger('created_by')->nullable();
|
||||
$table->unsignedBigInteger('updated_by')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['segment_id', 'ip_address']);
|
||||
$table->foreign('segment_id')->references('id')->on('network_segments')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('network_ip_notes');
|
||||
}
|
||||
};
|
||||
+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');
|
||||
}
|
||||
};
|
||||
@@ -10,6 +10,7 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
$this->call([
|
||||
RolesAndPermissionsSeeder::class,
|
||||
SettingsSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class SettingsSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$defaults = [
|
||||
'site_name' => 'Network-MGMT',
|
||||
'site_logo' => '',
|
||||
'button_color' => '#4f46e5', // Indigo-600
|
||||
'theme_mode' => 'light', // light | dark
|
||||
];
|
||||
|
||||
foreach ($defaults as $key => $value) {
|
||||
Setting::firstOrCreate(['key' => $key], ['value' => $value]);
|
||||
}
|
||||
|
||||
$this->command->info('Default settings seeded.');
|
||||
}
|
||||
}
|
||||
@@ -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,137 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
Layout-Einstellungen
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
@if(session('success'))
|
||||
<div class="p-4 bg-green-100 text-green-800 rounded-md">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('admin.layout.update') }}" enctype="multipart/form-data"
|
||||
class="bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg p-6 space-y-8">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- Site-Name --}}
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4 pb-2 border-b border-gray-200">
|
||||
Allgemein
|
||||
</h3>
|
||||
<div>
|
||||
<x-input-label for="site_name" value="Seitenname" />
|
||||
<x-text-input id="site_name" name="site_name" type="text" class="mt-1 block w-full"
|
||||
value="{{ old('site_name', $settings['site_name'] ?? 'Network-MGMT') }}" required />
|
||||
<p class="mt-1 text-xs text-gray-500">Wird im Browser-Tab und in der Navigation angezeigt.</p>
|
||||
<x-input-error :messages="$errors->get('site_name')" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Logo --}}
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4 pb-2 border-b border-gray-200">
|
||||
Logo
|
||||
</h3>
|
||||
|
||||
@if(!empty($settings['site_logo']))
|
||||
<div class="mb-4 flex items-center space-x-4">
|
||||
<img src="{{ asset('storage/' . $settings['site_logo']) }}"
|
||||
alt="Logo" class="h-12 object-contain border rounded p-1 bg-gray-50">
|
||||
<a href="{{ route('admin.layout.removeLogo') }}"
|
||||
onclick="return confirm('Logo wirklich entfernen?')"
|
||||
class="text-sm text-red-600 hover:text-red-800">Logo entfernen</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<x-input-label for="site_logo" value="Logo hochladen (PNG, JPG – max. 2 MB)" />
|
||||
<input id="site_logo" name="site_logo" type="file" accept="image/*"
|
||||
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-md file:border-0 file:text-sm file:font-medium
|
||||
file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" />
|
||||
<x-input-error :messages="$errors->get('site_logo')" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Button-Farbe --}}
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4 pb-2 border-b border-gray-200">
|
||||
Button-Farbe
|
||||
</h3>
|
||||
<div class="flex items-center space-x-4">
|
||||
<input id="button_color" name="button_color" type="color"
|
||||
value="{{ old('button_color', $settings['button_color'] ?? '#4f46e5') }}"
|
||||
class="h-10 w-20 rounded border border-gray-300 cursor-pointer p-1" />
|
||||
<div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">Primärfarbe für Buttons und Akzente</p>
|
||||
<p class="text-xs text-gray-500" id="color_preview_text">
|
||||
{{ old('button_color', $settings['button_color'] ?? '#4f46e5') }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
id="preview_btn"
|
||||
style="background-color: {{ old('button_color', $settings['button_color'] ?? '#4f46e5') }}"
|
||||
class="px-4 py-2 text-white text-sm font-medium rounded-md transition">
|
||||
Vorschau
|
||||
</button>
|
||||
</div>
|
||||
<x-input-error :messages="$errors->get('button_color')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
{{-- Dark / Light Mode --}}
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4 pb-2 border-b border-gray-200">
|
||||
Erscheinungsbild
|
||||
</h3>
|
||||
<div class="flex space-x-4">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="radio" name="theme_mode" value="light"
|
||||
{{ ($settings['theme_mode'] ?? 'light') === 'light' ? 'checked' : '' }}
|
||||
class="text-indigo-600 focus:ring-indigo-500" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
☀️ Hell (Light Mode)
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="radio" name="theme_mode" value="dark"
|
||||
{{ ($settings['theme_mode'] ?? 'light') === 'dark' ? 'checked' : '' }}
|
||||
class="text-indigo-600 focus:ring-indigo-500" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
🌙 Dunkel (Dark Mode)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Speichern --}}
|
||||
<div class="flex justify-end pt-2 border-t border-gray-200">
|
||||
<button type="submit"
|
||||
id="save_btn"
|
||||
style="background-color: {{ $settings['button_color'] ?? '#4f46e5' }}"
|
||||
class="inline-flex items-center px-6 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
|
||||
Einstellungen speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const colorInput = document.getElementById('button_color');
|
||||
const previewBtn = document.getElementById('preview_btn');
|
||||
const saveBtn = document.getElementById('save_btn');
|
||||
const colorText = document.getElementById('color_preview_text');
|
||||
|
||||
colorInput.addEventListener('input', function () {
|
||||
previewBtn.style.backgroundColor = this.value;
|
||||
saveBtn.style.backgroundColor = this.value;
|
||||
colorText.textContent = this.value;
|
||||
});
|
||||
</script>
|
||||
</x-app-layout>
|
||||
@@ -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>
|
||||
@@ -1,19 +1,21 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Benutzerverwaltung
|
||||
</h2>
|
||||
<a href="{{ route('admin.users.create') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700 transition">
|
||||
+ Neuer Benutzer
|
||||
</a>
|
||||
</div>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Benutzerverwaltung
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
|
||||
{{-- Toolbar --}}
|
||||
<div class="flex justify-end mb-4">
|
||||
<a href="{{ route('admin.users.create') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700 transition">
|
||||
+ Neuer Benutzer
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Flash-Meldungen --}}
|
||||
@if(session('success'))
|
||||
<div class="mb-4 p-4 bg-green-100 text-green-800 rounded-md">
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" {{ $attributes }}>
|
||||
<path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"/>
|
||||
</svg>
|
||||
@php
|
||||
$logoPath = $appSettings['site_logo'] ?? '';
|
||||
@endphp
|
||||
|
||||
@if(!empty($logoPath))
|
||||
<img src="{{ asset('storage/' . $logoPath) }}"
|
||||
alt="{{ $appSettings['site_name'] ?? config('app.name') }}"
|
||||
{{ $attributes->merge(['class' => 'object-contain']) }}
|
||||
style="max-height: 2.25rem;" />
|
||||
@else
|
||||
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" {{ $attributes }}>
|
||||
<path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"/>
|
||||
</svg>
|
||||
@endif
|
||||
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.3 KiB |
@@ -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>
|
||||
@@ -0,0 +1,42 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
Changelog
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg p-8">
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
@php
|
||||
// Einfaches Markdown-to-HTML für den Changelog
|
||||
$lines = explode("\n", $content);
|
||||
$html = '';
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with($line, '## ')) {
|
||||
$html .= '<h2 class="text-xl font-bold mt-6 mb-2 text-gray-900 dark:text-gray-100 border-b pb-1">'
|
||||
. e(substr($line, 3)) . '</h2>';
|
||||
} elseif (str_starts_with($line, '### ')) {
|
||||
$html .= '<h3 class="text-base font-semibold mt-4 mb-1 text-indigo-700 dark:text-indigo-400">'
|
||||
. e(substr($line, 4)) . '</h3>';
|
||||
} elseif (str_starts_with($line, '# ')) {
|
||||
$html .= '<h1 class="text-2xl font-bold mb-4 text-gray-900 dark:text-gray-100">'
|
||||
. e(substr($line, 2)) . '</h1>';
|
||||
} elseif (str_starts_with($line, '- ')) {
|
||||
$html .= '<li class="ml-4 text-sm text-gray-700 dark:text-gray-300 list-disc">'
|
||||
. e(substr($line, 2)) . '</li>';
|
||||
} elseif (trim($line) === '---') {
|
||||
$html .= '<hr class="my-4 border-gray-200 dark:border-gray-700">';
|
||||
} elseif (trim($line) !== '') {
|
||||
$html .= '<p class="text-sm text-gray-600 dark:text-gray-400 my-1">'
|
||||
. e($line) . '</p>';
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
{!! $html !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,77 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
Handbuch
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg p-8 space-y-8">
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-3">Erste Schritte</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 text-sm leading-relaxed">
|
||||
Network-MGMT ist eine webbasierte Verwaltungsplattform für Netzwerk-Ressourcen
|
||||
mit rollenbasierter Zugriffskontrolle.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-3">Rollen & Rechte</h2>
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Rolle</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Beschreibung</th>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Berechtigungen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
<td class="px-4 py-2"><span class="px-2 py-0.5 bg-red-100 text-red-800 rounded-full text-xs">admin</span></td>
|
||||
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">Administrator</td>
|
||||
<td class="px-4 py-2 text-gray-600 dark:text-gray-400">Vollzugriff auf alle Funktionen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 py-2"><span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 rounded-full text-xs">manager</span></td>
|
||||
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">Manager</td>
|
||||
<td class="px-4 py-2 text-gray-600 dark:text-gray-400">Netzwerk lesen, anlegen, bearbeiten; Benutzer lesen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 py-2"><span class="px-2 py-0.5 bg-blue-100 text-blue-800 rounded-full text-xs">user</span></td>
|
||||
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">Benutzer</td>
|
||||
<td class="px-4 py-2 text-gray-600 dark:text-gray-400">Netzwerk lesen</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-3">Benutzerverwaltung</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 text-sm leading-relaxed">
|
||||
Unter <strong>Einstellungen → Benutzerverwaltung</strong> können Administratoren
|
||||
neue Benutzer anlegen, bestehende bearbeiten und Rollen zuweisen.
|
||||
Der eigene Account kann nicht gelöscht werden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-3">Layout-Einstellungen</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 text-sm leading-relaxed">
|
||||
Unter <strong>Einstellungen → Layout</strong> kann der Seitenname, das Logo,
|
||||
die Button-Farbe sowie der Dark/Light-Mode konfiguriert werden.
|
||||
Änderungen werden sofort für alle Benutzer wirksam.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-400 dark:text-gray-500">
|
||||
Network-MGMT · Version {{ config('app.version', '0.4.0') }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -1,11 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
|
||||
class="{{ ($appSettings['theme_mode'] ?? 'light') === 'dark' ? 'dark' : '' }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ config('app.name', 'Laravel') }}</title>
|
||||
<title>{{ $appSettings['site_name'] ?? config('app.name', 'Network-MGMT') }}</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
@@ -13,14 +14,28 @@
|
||||
|
||||
<!-- Scripts -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
<!-- Dynamic Settings -->
|
||||
<style>
|
||||
:root {
|
||||
--color-primary: {{ $appSettings['button_color'] ?? '#4f46e5' }};
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary) !important;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||
@include('layouts.navigation')
|
||||
|
||||
<!-- Page Heading -->
|
||||
@isset($header)
|
||||
<header class="bg-white shadow">
|
||||
<header class="bg-white dark:bg-gray-800 shadow">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
{{ $header }}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
|
||||
<nav x-data="{ open: false }" class="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700">
|
||||
<!-- Primary Navigation Menu -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
@@ -15,11 +15,94 @@
|
||||
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||
{{ __('Dashboard') }}
|
||||
</x-nav-link>
|
||||
|
||||
@role('admin')
|
||||
<x-nav-link :href="route('admin.users.index')" :active="request()->routeIs('admin.*')">
|
||||
Benutzerverwaltung
|
||||
</x-nav-link>
|
||||
{{-- Einstellungen-Dropdown --}}
|
||||
<x-dropdown align="left" width="48">
|
||||
<x-slot name="trigger">
|
||||
<button class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none
|
||||
{{ request()->routeIs('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>
|
||||
</button>
|
||||
</x-slot>
|
||||
<x-slot name="content">
|
||||
<x-dropdown-link :href="route('admin.users.index')">
|
||||
👥 Benutzerverwaltung
|
||||
</x-dropdown-link>
|
||||
<x-dropdown-link :href="route('admin.layout.index')">
|
||||
🎨 Layout
|
||||
</x-dropdown-link>
|
||||
<x-dropdown-link :href="route('admin.update.index')">
|
||||
🔄 Software-Update
|
||||
@if($navUpdateAvailable)
|
||||
<span class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-semibold bg-amber-400 text-amber-900">neu</span>
|
||||
@endif
|
||||
</x-dropdown-link>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
@endrole
|
||||
|
||||
{{-- Netzwerk-Dropdown --}}
|
||||
<x-dropdown align="left" width="48">
|
||||
<x-slot name="trigger">
|
||||
<button class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none
|
||||
{{ request()->routeIs('network.*') ? 'border-indigo-400 text-gray-900 dark:text-gray-100' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300' }}">
|
||||
🌐 Netzwerk
|
||||
<svg class="ms-1 fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</x-slot>
|
||||
<x-slot name="content">
|
||||
<x-dropdown-link :href="route('network.dashboard')">
|
||||
📊 Dashboard
|
||||
</x-dropdown-link>
|
||||
<x-dropdown-link :href="route('network.segments.index')">
|
||||
🗂️ Segmente
|
||||
</x-dropdown-link>
|
||||
<x-dropdown-link :href="route('network.devices')">
|
||||
💻 Alle Geräte
|
||||
</x-dropdown-link>
|
||||
<x-dropdown-link :href="route('network.search')">
|
||||
🔍 Suche
|
||||
</x-dropdown-link>
|
||||
<x-dropdown-link :href="route('network.history')">
|
||||
📅 IP-Verlauf
|
||||
</x-dropdown-link>
|
||||
<x-dropdown-link :href="route('network.import')">
|
||||
⬆️ Scan importieren
|
||||
</x-dropdown-link>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
|
||||
{{-- Hilfe-Dropdown --}}
|
||||
<x-dropdown align="left" width="48">
|
||||
<x-slot name="trigger">
|
||||
<button class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none
|
||||
{{ request()->routeIs('help.*') ? 'border-indigo-400 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||
Hilfe
|
||||
<svg class="ms-1 fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</x-slot>
|
||||
<x-slot name="content">
|
||||
<x-dropdown-link :href="route('help.manual')">
|
||||
📖 Handbuch
|
||||
</x-dropdown-link>
|
||||
<x-dropdown-link :href="route('help.changelog')">
|
||||
📋 Changelog
|
||||
</x-dropdown-link>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,11 +158,47 @@
|
||||
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||
{{ __('Dashboard') }}
|
||||
</x-responsive-nav-link>
|
||||
|
||||
@role('admin')
|
||||
<x-responsive-nav-link :href="route('admin.users.index')" :active="request()->routeIs('admin.*')">
|
||||
Benutzerverwaltung
|
||||
</x-responsive-nav-link>
|
||||
<div class="pt-2 pb-1 border-t border-gray-200">
|
||||
<div class="px-4 py-1 text-xs font-semibold text-gray-400 uppercase tracking-wider">Einstellungen</div>
|
||||
<x-responsive-nav-link :href="route('admin.users.index')">
|
||||
👥 Benutzerverwaltung
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('admin.layout.index')">
|
||||
🎨 Layout
|
||||
</x-responsive-nav-link>
|
||||
</div>
|
||||
@endrole
|
||||
|
||||
<div class="pt-2 pb-1 border-t border-gray-200">
|
||||
<div class="px-4 py-1 text-xs font-semibold text-gray-400 uppercase tracking-wider">Netzwerk</div>
|
||||
<x-responsive-nav-link :href="route('network.dashboard')">
|
||||
📊 Dashboard
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('network.segments.index')">
|
||||
🗂️ Segmente
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('network.devices')">
|
||||
💻 Alle Geräte
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('network.search')">
|
||||
🔍 Suche
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('network.import')">
|
||||
⬆️ Scan importieren
|
||||
</x-responsive-nav-link>
|
||||
</div>
|
||||
|
||||
<div class="pt-2 pb-1 border-t border-gray-200">
|
||||
<div class="px-4 py-1 text-xs font-semibold text-gray-400 uppercase tracking-wider">Hilfe</div>
|
||||
<x-responsive-nav-link :href="route('help.manual')">
|
||||
📖 Handbuch
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('help.changelog')">
|
||||
📋 Changelog
|
||||
</x-responsive-nav-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Responsive Settings Options -->
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Netzwerk-Dashboard</h2>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('network.segments.create') }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
+ Segment anlegen
|
||||
</a>
|
||||
<a href="{{ route('network.import') }}" style="background-color: var(--color-primary)"
|
||||
class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
|
||||
+ Scan importieren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
{{-- KPI-Karten --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Segmente</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">{{ $segments->count() }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Geräte gesamt</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">{{ $totalDevices }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Aktuell online</p>
|
||||
<p class="mt-1 text-2xl font-bold text-green-600 dark:text-green-400">{{ $onlineDevices }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Offene Ereignisse</p>
|
||||
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{{ $recentEvents->count() + $ipChangeEvents->count() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ⚠️ IP-Wechsel – prominenter Warnblock --}}
|
||||
@if($ipChangeEvents->count() > 0)
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-amber-200 dark:border-amber-700 flex items-center justify-between">
|
||||
<h3 class="font-semibold text-amber-800 dark:text-amber-300 flex items-center gap-2">
|
||||
⚠️ IP-Adressen-Wechsel erkannt
|
||||
<span class="bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-200 text-xs px-2 py-0.5 rounded-full font-bold">
|
||||
{{ $ipChangeEvents->count() }}
|
||||
</span>
|
||||
</h3>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">Bitte prüfen und quittieren</span>
|
||||
</div>
|
||||
<div class="divide-y divide-amber-100 dark:divide-amber-800">
|
||||
@foreach($ipChangeEvents as $event)
|
||||
<div class="px-5 py-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
{{-- Gerät und IP-Wechsel --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center flex-wrap gap-2 mb-1">
|
||||
<a href="{{ route('network.device', $event->device) }}"
|
||||
class="font-semibold text-gray-900 dark:text-gray-100 hover:text-indigo-600">
|
||||
{{ $event->device->display_name }}
|
||||
</a>
|
||||
@if($event->device->mac_address)
|
||||
<span class="font-mono text-xs text-gray-400 bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded">
|
||||
{{ $event->device->mac_address }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-mono text-sm font-bold text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-2 py-0.5 rounded">
|
||||
{{ $event->old_value }}
|
||||
</span>
|
||||
<span class="text-gray-400">→</span>
|
||||
<span class="font-mono text-sm font-bold text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-2 py-0.5 rounded">
|
||||
{{ $event->new_value }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ $event->created_at->format('d.m.Y H:i') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{-- Bestätigen mit Notiz --}}
|
||||
<form method="POST" action="{{ route('network.document', $event) }}"
|
||||
class="flex gap-2 items-center shrink-0">
|
||||
@csrf
|
||||
<input type="text" name="description"
|
||||
placeholder="Notiz zur Änderung (optional)"
|
||||
class="text-xs border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded px-2 py-1.5 w-56 focus:ring-indigo-500 focus:border-indigo-500" />
|
||||
<button type="submit"
|
||||
class="shrink-0 text-xs px-3 py-1.5 rounded bg-green-600 text-white hover:bg-green-700 transition font-medium">
|
||||
✓ Quittieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{{-- IP-Verlauf (sofern vorhanden) --}}
|
||||
@php
|
||||
$ipCount = $event->device->hosts()
|
||||
->selectRaw('DISTINCT ip_address')
|
||||
->count();
|
||||
@endphp
|
||||
@if($ipCount > 1)
|
||||
<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
📋 Dieses Gerät hatte {{ $ipCount }} verschiedene IP-Adressen —
|
||||
<a href="{{ route('network.device', $event->device) }}" class="underline hover:no-underline">IP-Verlauf anzeigen →</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Globale Suche --}}
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-5">
|
||||
<form method="GET" action="{{ route('network.search') }}" class="flex gap-3">
|
||||
<input type="text" name="q" placeholder="IP-Adresse, MAC, Hostname, Bezeichnung über alle Segmente suchen..."
|
||||
class="flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500" />
|
||||
<button type="submit" style="background-color: var(--color-primary)"
|
||||
class="px-5 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition whitespace-nowrap">
|
||||
🔍 Suchen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Segmente --}}
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Netzwerk-Segmente</h3>
|
||||
<a href="{{ route('network.segments.index') }}" class="text-xs text-indigo-600 hover:underline">Alle verwalten →</a>
|
||||
</div>
|
||||
|
||||
@if($segments->isEmpty())
|
||||
<div class="px-5 py-10 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-3">Noch keine Segmente definiert.</p>
|
||||
<a href="{{ route('network.segments.create') }}" style="background-color: var(--color-primary)"
|
||||
class="inline-block px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
|
||||
Erstes Segment anlegen
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Subnetz</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">VLAN</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Letzter Scan</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Online / Gesamt</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Scans</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@foreach($segments as $segment)
|
||||
@php $lastScan = $latestScans->get($segment->id)?->first(); @endphp
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-block w-2 h-2 rounded-full {{ $segment->active ? 'bg-green-500' : 'bg-gray-300' }}"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
<a href="{{ route('network.segments.show', $segment) }}" class="hover:text-indigo-600">
|
||||
{{ $segment->name }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{{ $segment->subnet }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $segment->vlan_id ? 'VLAN ' . $segment->vlan_id : '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $lastScan?->created_at->format('d.m.Y H:i') ?? '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs">
|
||||
@if($lastScan)
|
||||
<span class="text-green-600 font-medium">{{ $lastScan->online_hosts }}</span>
|
||||
<span class="text-gray-400"> / {{ $lastScan->total_hosts }}</span>
|
||||
@else
|
||||
<span class="text-gray-400">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $segment->scans_count }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Sonstige undokumentierte Ereignisse --}}
|
||||
@if($recentEvents->count() > 0)
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Undokumentierte Ereignisse
|
||||
<span class="ml-2 bg-yellow-100 text-yellow-800 text-xs px-2 py-0.5 rounded-full">{{ $recentEvents->count() }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@foreach($recentEvents as $event)
|
||||
<div class="flex items-center justify-between px-5 py-3 gap-4">
|
||||
<div class="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<span class="w-2 h-2 rounded-full flex-shrink-0
|
||||
{{ $event->event_color === 'green' ? 'bg-green-500' :
|
||||
($event->event_color === 'blue' ? 'bg-blue-500' :
|
||||
($event->event_color === 'red' ? 'bg-red-500' : 'bg-yellow-500')) }}">
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $event->event_label }}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">
|
||||
<a href="{{ route('network.device', $event->device) }}" class="hover:text-indigo-600">
|
||||
{{ $event->device->display_name }}
|
||||
</a>
|
||||
· {{ $event->created_at->format('d.m.Y H:i') }}
|
||||
</span>
|
||||
@if($event->old_value && $event->new_value)
|
||||
<span class="ml-2 text-xs text-gray-400">
|
||||
<span class="font-mono">{{ $event->old_value }}</span> → <span class="font-mono">{{ $event->new_value }}</span>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('network.document', $event) }}"
|
||||
class="flex gap-2 items-center shrink-0">
|
||||
@csrf
|
||||
<input type="text" name="description" placeholder="Notiz..."
|
||||
class="text-xs border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded px-2 py-1 w-40 focus:ring-indigo-500 focus:border-indigo-500" />
|
||||
<button type="submit"
|
||||
class="text-xs px-3 py-1 rounded border border-green-300 text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 transition whitespace-nowrap">
|
||||
✓ Quittieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,211 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="{{ route('network.devices') }}" class="text-gray-500 hover:text-gray-700">Geräte</a>
|
||||
<span class="text-gray-400">/</span>
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">{{ $device->display_name }}</h2>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
@if(session('success'))
|
||||
<div class="p-4 bg-green-100 text-green-800 rounded-md">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Stammdaten --}}
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="w-3 h-3 rounded-full {{ $device->status === 'online' ? 'bg-green-500' : 'bg-red-400' }}"></span>
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ $device->display_name }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Erstmals gesehen: {{ $device->first_seen_at?->format('d.m.Y H:i') }}
|
||||
· Zuletzt: {{ $device->last_seen_at?->format('d.m.Y H:i') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">IP-Adresse</p>
|
||||
<p class="mt-1 font-mono font-semibold text-gray-900 dark:text-gray-100">{{ $device->current_ip ?? '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">MAC-Adresse</p>
|
||||
<p class="mt-1 font-mono font-semibold text-gray-900 dark:text-gray-100">{{ $device->mac_address }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Hersteller</p>
|
||||
<p class="mt-1 text-gray-900 dark:text-gray-100">{{ $device->mac_vendor ?? '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Hostname</p>
|
||||
<p class="mt-1 text-gray-900 dark:text-gray-100">{{ $device->hostname ?? '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">NetBIOS</p>
|
||||
<p class="mt-1 text-gray-900 dark:text-gray-100">{{ $device->netbios_name ?? '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Offene Ports</p>
|
||||
<p class="mt-1 text-gray-900 dark:text-gray-100">{{ $device->ports ?? '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Bezeichnung & Notiz --}}
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-6">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Bezeichnung & Notizen</h3>
|
||||
<form method="POST" action="{{ route('network.device.update', $device) }}" class="space-y-4">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<div>
|
||||
<x-input-label for="label" value="Bezeichnung (eigener Name)" />
|
||||
<x-text-input id="label" name="label" type="text" class="mt-1 block w-full"
|
||||
value="{{ old('label', $device->label) }}"
|
||||
placeholder="z.B. NAS-Server, Drucker Büro, ..." />
|
||||
</div>
|
||||
<div>
|
||||
<x-input-label for="notes" value="Notizen" />
|
||||
<textarea id="notes" name="notes" rows="3"
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Interne Notizen zu diesem Gerät...">{{ old('notes', $device->notes) }}</textarea>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<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">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Manuelle Notiz --}}
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-6">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">Manuelle Notiz hinzufügen</h3>
|
||||
<form method="POST" action="{{ route('network.device.note', $device) }}" class="flex gap-3">
|
||||
@csrf
|
||||
<input type="text" name="description" required
|
||||
placeholder="Änderung, Beobachtung, Aufgabe..."
|
||||
class="flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500" />
|
||||
<button type="submit" style="background-color: var(--color-primary)"
|
||||
class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition whitespace-nowrap">
|
||||
+ Notiz
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- 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 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>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Datum</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">IP</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||
<th class="px-4 py-2 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">
|
||||
@foreach($ipHistory as $h)
|
||||
<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 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>
|
||||
<td class="px-4 py-2 text-gray-500 dark:text-gray-400">{{ $h->ping_ms ? $h->ping_ms . ' ms' : '—' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Ereignis-Protokoll --}}
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Ereignis-Protokoll</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@forelse($device->events as $event)
|
||||
<div class="flex items-start justify-between px-5 py-3">
|
||||
<div class="flex items-start space-x-3">
|
||||
<span class="mt-1 w-2 h-2 rounded-full flex-shrink-0
|
||||
{{ $event->event_color === 'blue' ? 'bg-blue-500' :
|
||||
($event->event_color === 'yellow' ? 'bg-yellow-500' :
|
||||
($event->event_color === 'green' ? 'bg-green-500' :
|
||||
($event->event_color === 'red' ? 'bg-red-500' :
|
||||
($event->event_color === 'purple' ? 'bg-purple-500' : 'bg-gray-400')))) }}">
|
||||
</span>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $event->event_label }}</span>
|
||||
@if($event->old_value && $event->new_value)
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ $event->old_value }} → {{ $event->new_value }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $event->description }}
|
||||
· {{ $event->created_at->format('d.m.Y H:i') }}
|
||||
@if($event->documented)
|
||||
· <span class="text-green-600">✓ dokumentiert</span>
|
||||
@if($event->documentedBy) von {{ $event->documentedBy->name }} @endif
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@if(!$event->documented)
|
||||
<form method="POST" action="{{ route('network.document', $event) }}" class="ml-4 flex-shrink-0">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="text-xs px-3 py-1 rounded border border-green-300 text-green-700 hover:bg-green-50 transition">
|
||||
✓ Bestätigen
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-gray-500">Keine Ereignisse vorhanden.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,105 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">Alle Geräte</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
|
||||
{{-- Filter --}}
|
||||
<form method="GET" action="{{ route('network.devices') }}" class="flex gap-3 mb-4">
|
||||
<input type="text" name="search" value="{{ request('search') }}"
|
||||
placeholder="IP, MAC, Hostname, Hersteller..."
|
||||
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" />
|
||||
<select name="status"
|
||||
class="border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="online" {{ request('status') === 'online' ? 'selected' : '' }}>Online</option>
|
||||
<option value="offline" {{ request('status') === 'offline' ? 'selected' : '' }}>Offline</option>
|
||||
</select>
|
||||
<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">
|
||||
Suchen
|
||||
</button>
|
||||
@if(request()->hasAny(['search', 'status']))
|
||||
<a href="{{ route('network.devices') }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
Zurücksetzen
|
||||
</a>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">IP-Adresse</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">MAC-Adresse</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hostname / Bezeichnung</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hersteller</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Zuletzt gesehen</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Ereignisse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@forelse($devices as $device)
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center">
|
||||
<span class="w-2 h-2 rounded-full mr-2
|
||||
{{ $device->status === 'online' ? 'bg-green-500' : 'bg-red-400' }}">
|
||||
</span>
|
||||
<span class="text-xs {{ $device->status === 'online' ? 'text-green-700 dark:text-green-400' : 'text-gray-500 dark:text-gray-400' }}">
|
||||
{{ $device->status }}
|
||||
</span>
|
||||
</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-gray-600 dark:text-gray-400 text-xs">
|
||||
{{ $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 ?? '—' }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 text-xs">
|
||||
{{ $device->mac_vendor ?? '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
|
||||
{{ $device->last_seen_at?->format('d.m.Y H:i') ?? '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
@php $undoc = $device->events->where('documented', false)->count(); @endphp
|
||||
<a href="{{ route('network.device', $device) }}"
|
||||
class="text-indigo-600 hover:text-indigo-800 text-xs">
|
||||
Detail
|
||||
@if($undoc > 0)
|
||||
<span class="ml-1 bg-yellow-100 text-yellow-800 text-xs px-1.5 py-0.5 rounded-full">{{ $undoc }}</span>
|
||||
@endif
|
||||
</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
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,61 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="{{ route('network.dashboard') }}" class="text-gray-500 hover:text-gray-700">Netzwerk</a>
|
||||
<span class="text-gray-400">/</span>
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Scan importieren</h2>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-6">
|
||||
|
||||
@if(session('success'))
|
||||
<div class="mb-4 p-4 bg-green-100 text-green-800 rounded-md">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Importiere den Textexport von <strong>Angry IP Scanner</strong>.<br>
|
||||
Exportformat: <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded">Datei → Speichern als → Komma-getrennte Textdatei (.txt)</code>
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ route('network.import') }}" enctype="multipart/form-data" class="space-y-5">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<x-input-label for="segment_id" value="Netzwerk-Segment (optional)" />
|
||||
<select id="segment_id" name="segment_id"
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">— Kein Segment —</option>
|
||||
@foreach($segments as $segment)
|
||||
<option value="{{ $segment->id }}"
|
||||
{{ (request('segment') == $segment->id || old('segment_id') == $segment->id) ? 'selected' : '' }}>
|
||||
{{ $segment->name }} ({{ $segment->subnet }}){{ $segment->vlan_id ? ' · VLAN ' . $segment->vlan_id : '' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-input-error :messages="$errors->get('segment_id')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="scan_file" value="Angry IP Scanner Export (.txt)" />
|
||||
<input id="scan_file" name="scan_file" type="file" accept=".txt,.csv" required
|
||||
class="mt-1 block w-full text-sm text-gray-500
|
||||
file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0
|
||||
file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700
|
||||
hover:file:bg-indigo-100 dark:file:bg-indigo-900 dark:file:text-indigo-300" />
|
||||
<x-input-error :messages="$errors->get('scan_file')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" style="background-color: var(--color-primary)"
|
||||
class="px-6 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
|
||||
Import starten
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,121 @@
|
||||
<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 leading-tight">Netzwerk-Übersicht</h2>
|
||||
<div class="flex space-x-2">
|
||||
<a href="{{ route('network.devices') }}"
|
||||
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">
|
||||
Alle Geräte
|
||||
</a>
|
||||
<a href="{{ route('network.import') }}"
|
||||
style="background-color: var(--color-primary)"
|
||||
class="px-3 py-2 text-sm font-medium rounded-md text-white hover:opacity-90 transition">
|
||||
+ Scan importieren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
@if(session('success'))
|
||||
<div class="p-4 bg-green-100 text-green-800 rounded-md">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- KPI-Karten --}}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-5">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Geräte gesamt</p>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900 dark:text-gray-100">{{ $totalDevices }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-5">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Aktuell online</p>
|
||||
<p class="mt-1 text-3xl font-bold text-green-600">{{ $onlineDevices }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-5">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Scans gesamt</p>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900 dark:text-gray-100">{{ $scans->count() }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-5">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Undokumentierte Ereignisse</p>
|
||||
<p class="mt-1 text-3xl font-bold text-yellow-600">{{ $recentEvents->count() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{{-- Letzte Scans --}}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Letzte Scans</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@forelse($scans as $scan)
|
||||
<a href="{{ route('network.scan', $scan) }}"
|
||||
class="flex items-center justify-between px-5 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $scan->subnet }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $scan->created_at->format('d.m.Y H:i') }} · {{ $scan->scanner }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-semibold text-green-600">{{ $scan->online_hosts }} online</span>
|
||||
@if($scan->new_devices > 0)
|
||||
<span class="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">{{ $scan->new_devices }} neu</span>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-gray-500">Noch keine Scans vorhanden.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Undokumentierte Ereignisse --}}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Undokumentierte Ereignisse</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@forelse($recentEvents as $event)
|
||||
<div class="flex items-start justify-between px-5 py-3">
|
||||
<div class="flex items-start space-x-3">
|
||||
<span class="mt-0.5 inline-block w-2 h-2 rounded-full flex-shrink-0
|
||||
{{ $event->event_color === 'blue' ? 'bg-blue-500' :
|
||||
($event->event_color === 'yellow' ? 'bg-yellow-500' :
|
||||
($event->event_color === 'green' ? 'bg-green-500' :
|
||||
($event->event_color === 'red' ? 'bg-red-500' : 'bg-gray-500'))) }}">
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $event->event_label }}
|
||||
@if($event->device)
|
||||
<a href="{{ route('network.device', $event->device) }}"
|
||||
class="text-indigo-600 hover:underline">
|
||||
{{ $event->device->display_name }}
|
||||
</a>
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $event->description }}
|
||||
· {{ $event->created_at->diffForHumans() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('network.document', $event) }}" class="ml-3 flex-shrink-0">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
✓ OK
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-green-600">Alle Ereignisse dokumentiert.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,157 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<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>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
{{-- Scan-Info --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
@foreach(['Subnetz' => $scan->subnet, 'Quelle' => $scan->scanner, 'Gesamt' => $scan->total_hosts, 'Online' => $scan->online_hosts, 'Neue Geräte' => $scan->new_devices] as $label => $val)
|
||||
<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">{{ $label }}</p>
|
||||
<p class="mt-1 text-xl font-bold text-gray-900 dark:text-gray-100">{{ $val }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Host-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-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">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">
|
||||
@foreach($hosts as $host)
|
||||
<tr class="{{ $host->status === 'online' ? '' : 'opacity-50' }} hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<td class="px-4 py-2">
|
||||
<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-2 font-mono font-medium text-gray-900 dark:text-gray-100">
|
||||
@if($host->device)
|
||||
<a href="{{ route('network.device', $host->device) }}" class="text-indigo-600 hover:underline">
|
||||
{{ $host->ip_address }}
|
||||
</a>
|
||||
@else
|
||||
{{ $host->ip_address }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-2 font-mono text-xs text-gray-600 dark:text-gray-400">{{ $host->mac_address ?? '—' }}</td>
|
||||
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{{ $host->hostname ?? '—' }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-gray-400">{{ $host->mac_vendor ?? '—' }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-gray-400">{{ $host->ping_ms ? $host->ping_ms . ' ms' : '—' }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 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>
|
||||
</table>
|
||||
</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>
|
||||
@@ -0,0 +1,149 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Globale Suche</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-4">
|
||||
|
||||
<form method="GET" action="{{ route('network.search') }}" class="flex gap-3">
|
||||
<input type="text" name="q" value="{{ $q }}"
|
||||
placeholder="IP-Adresse, MAC, Hostname, Bezeichnung..."
|
||||
autofocus
|
||||
class="flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500" />
|
||||
<button type="submit" style="background-color: var(--color-primary)"
|
||||
class="px-5 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
|
||||
🔍 Suchen
|
||||
</button>
|
||||
@if($q)
|
||||
<a href="{{ route('network.search') }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
Zurücksetzen
|
||||
</a>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
@if($q && strlen($q) < 2)
|
||||
<p class="text-sm text-gray-500">Mindestens 2 Zeichen eingeben.</p>
|
||||
@elseif($q)
|
||||
@php $totalFound = $devices->total() + $hostResults->count(); @endphp
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $totalFound }} Ergebnis(se) für „{{ $q }}" über alle Segmente
|
||||
</p>
|
||||
|
||||
{{-- Ergebnisse aus network_devices (getracked) --}}
|
||||
@if($devices->count() > 0)
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Geräte (bekannt)</span>
|
||||
</div>
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">IP-Adresse</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">MAC-Adresse</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hostname / Bezeichnung</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hersteller</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Zuletzt gesehen</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@foreach($devices as $device)
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<td class="px-4 py-3">
|
||||
<span class="w-2 h-2 rounded-full inline-block
|
||||
{{ $device->status === 'online' ? 'bg-green-500' : 'bg-red-400' }}"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-900 dark:text-gray-100 font-medium">{{ $device->current_ip }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{{ $device->mac_address ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-gray-100">
|
||||
@if($device->label)
|
||||
<span class="font-medium">{{ $device->label }}</span>
|
||||
<span class="text-gray-400 text-xs ml-1">({{ $device->hostname }})</span>
|
||||
@else
|
||||
{{ $device->hostname ?? $device->netbios_name ?? '—' }}
|
||||
@endif
|
||||
@if($device->netbios_name && $device->netbios_name !== $device->hostname)
|
||||
<span class="block text-xs text-gray-400">NetBIOS: {{ $device->netbios_name }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-600 dark:text-gray-400">{{ $device->mac_vendor ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $device->last_seen_at?->format('d.m.Y H:i') ?? '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="{{ route('network.device', $device) }}"
|
||||
class="text-xs text-indigo-600 hover:underline">Detail →</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@if($devices->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
|
||||
{{ $devices->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Ergebnisse aus network_hosts (Scan-Treffer ohne Device-Eintrag) --}}
|
||||
@if($hostResults->count() > 0)
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-2 bg-blue-50 dark:bg-blue-900/20 border-b border-blue-100 dark:border-blue-800 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wider">
|
||||
In Scan-Verlauf gefunden
|
||||
</span>
|
||||
<span class="text-xs text-blue-400">Noch kein Geräteeintrag — wird beim nächsten Scan angelegt</span>
|
||||
</div>
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">IP-Adresse</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">MAC-Adresse</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hostname</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Hersteller</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Segment</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Letzter Scan</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@foreach($hostResults as $host)
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<td class="px-4 py-3">
|
||||
<span class="w-2 h-2 rounded-full inline-block
|
||||
{{ $host->status === 'online' ? 'bg-green-500' : 'bg-gray-300' }}"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">{{ $host->ip_address }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-500 dark:text-gray-400">{{ $host->mac_address ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ $host->hostname ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{{ $host->mac_vendor ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{{ $host->segment_name ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-400">
|
||||
{{ \Carbon\Carbon::parse($host->scan_time)->format('d.m.Y H:i') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="{{ route('network.scan', $host->scan_id) }}"
|
||||
class="text-xs text-indigo-600 hover:underline">Scan →</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($totalFound === 0)
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg px-4 py-10 text-center text-gray-500">
|
||||
Keine Ergebnisse für „{{ $q }}" gefunden.
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,129 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="{{ route('network.segments.index') }}" class="text-gray-500 hover:text-gray-700">Segmente</a>
|
||||
<span class="text-gray-400">/</span>
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Neues Segment</h2>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-6">
|
||||
<form method="POST" action="{{ route('network.segments.store') }}" class="space-y-5">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<x-input-label for="name" value="Name *" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
|
||||
value="{{ old('name') }}" placeholder="z.B. Büro, Server-VLAN, Produktion" required />
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="subnet" value="Subnetz *" />
|
||||
<div class="flex gap-2 mt-1">
|
||||
<x-text-input id="subnet" name="subnet" type="text" class="block w-full font-mono"
|
||||
value="{{ old('subnet') }}" placeholder="z.B. 192.168.1.0/24 oder 10.0.0.0/8" required />
|
||||
<button type="button" id="detect-subnet-btn"
|
||||
class="shrink-0 px-3 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
🔍 Erkennen
|
||||
</button>
|
||||
</div>
|
||||
<x-input-error :messages="$errors->get('subnet')" class="mt-1" />
|
||||
<div id="subnet-suggestions" class="mt-1 hidden"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="vlan_id" value="VLAN-ID (optional)" />
|
||||
<x-text-input id="vlan_id" name="vlan_id" type="number" class="mt-1 block w-full"
|
||||
value="{{ old('vlan_id') }}" placeholder="z.B. 10" min="1" max="4094" />
|
||||
<x-input-error :messages="$errors->get('vlan_id')" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="description" value="Beschreibung (optional)" />
|
||||
<textarea id="description" name="description" rows="2"
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Kurze Beschreibung dieses Segments...">{{ old('description') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="scan_interval_minutes" value="Scan-Zyklus (automatisch)" />
|
||||
<select id="scan_interval_minutes" name="scan_interval_minutes"
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">Manuell (kein Auto-Scan)</option>
|
||||
<option value="5" {{ old('scan_interval_minutes') == '5' ? 'selected' : '' }}>Alle 5 Minuten</option>
|
||||
<option value="15" {{ old('scan_interval_minutes') == '15' ? 'selected' : '' }}>Alle 15 Minuten</option>
|
||||
<option value="30" {{ old('scan_interval_minutes') == '30' ? 'selected' : '' }}>Alle 30 Minuten</option>
|
||||
<option value="60" {{ old('scan_interval_minutes') == '60' ? 'selected' : '' }}>Stündlich</option>
|
||||
<option value="360" {{ old('scan_interval_minutes') == '360' ? 'selected' : '' }}>Alle 6 Stunden</option>
|
||||
<option value="720" {{ old('scan_interval_minutes') == '720' ? 'selected' : '' }}>Alle 12 Stunden</option>
|
||||
<option value="1440" {{ old('scan_interval_minutes') == '1440' ? 'selected' : '' }}>Täglich</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500">Erfordert aktiven Laravel Scheduler (Cron).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="nmap_options" value="nmap-Parameter" />
|
||||
<x-text-input id="nmap_options" name="nmap_options" type="text" class="mt-1 block w-full font-mono"
|
||||
value="{{ old('nmap_options', '-sn') }}" placeholder="-sn" />
|
||||
<p class="mt-1 text-xs text-gray-500">-sn = Ping-Scan (schnell) · -sn -p 22,80,443 = mit Ports</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="active" name="active" value="1"
|
||||
{{ old('active', '1') ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<label for="active" class="text-sm text-gray-700 dark:text-gray-300">Aktiv (automatisch überwachen)</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="{{ route('network.segments.index') }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 transition">
|
||||
Abbrechen
|
||||
</a>
|
||||
<button type="submit" style="background-color: var(--color-primary)"
|
||||
class="px-5 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
|
||||
Segment anlegen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('detect-subnet-btn').addEventListener('click', function() {
|
||||
const btn = this;
|
||||
const suggestions = document.getElementById('subnet-suggestions');
|
||||
btn.textContent = '⏳ Suche...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('{{ route('network.detect-subnets') }}', {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(subnets) {
|
||||
btn.textContent = '🔍 Erkennen';
|
||||
btn.disabled = false;
|
||||
if (!subnets.length) {
|
||||
suggestions.innerHTML = '<p class="text-xs text-gray-400">Keine Daten in der Datenbank gefunden.</p>';
|
||||
suggestions.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
suggestions.innerHTML = '<p class="text-xs text-gray-500 mb-1">Erkannte Subnetze – klicken zum Übernehmen:</p>' +
|
||||
subnets.map(function(s) {
|
||||
return '<button type="button" onclick="document.getElementById(\'subnet\').value=\'' + s + '\'" ' +
|
||||
'class="mr-1 mb-1 px-2 py-1 text-xs font-mono rounded border border-indigo-300 text-indigo-700 hover:bg-indigo-50 transition">' + s + '</button>';
|
||||
}).join('');
|
||||
suggestions.classList.remove('hidden');
|
||||
})
|
||||
.catch(function() {
|
||||
btn.textContent = '🔍 Erkennen';
|
||||
btn.disabled = false;
|
||||
suggestions.innerHTML = '<p class="text-xs text-red-500">Fehler bei der Erkennung.</p>';
|
||||
suggestions.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,125 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="{{ route('network.segments.index') }}" class="text-gray-500 hover:text-gray-700">Segmente</a>
|
||||
<span class="text-gray-400">/</span>
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">{{ $segment->name }} bearbeiten</h2>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-6">
|
||||
<form method="POST" action="{{ route('network.segments.update', $segment) }}" class="space-y-5">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<div>
|
||||
<x-input-label for="name" value="Name *" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
|
||||
value="{{ old('name', $segment->name) }}" required />
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="subnet" value="Subnetz *" />
|
||||
<div class="flex gap-2 mt-1">
|
||||
<x-text-input id="subnet" name="subnet" type="text" class="block w-full font-mono"
|
||||
value="{{ old('subnet', $segment->subnet) }}" required />
|
||||
<button type="button" id="detect-subnet-btn"
|
||||
class="shrink-0 px-3 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
🔍 Erkennen
|
||||
</button>
|
||||
</div>
|
||||
<x-input-error :messages="$errors->get('subnet')" class="mt-1" />
|
||||
<div id="subnet-suggestions" class="mt-1 hidden"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="vlan_id" value="VLAN-ID (optional)" />
|
||||
<x-text-input id="vlan_id" name="vlan_id" type="number" class="mt-1 block w-full"
|
||||
value="{{ old('vlan_id', $segment->vlan_id) }}" min="1" max="4094" />
|
||||
<x-input-error :messages="$errors->get('vlan_id')" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="description" value="Beschreibung (optional)" />
|
||||
<textarea id="description" name="description" rows="2"
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">{{ old('description', $segment->description) }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="scan_interval_minutes" value="Scan-Zyklus (automatisch)" />
|
||||
<select id="scan_interval_minutes" name="scan_interval_minutes"
|
||||
class="mt-1 block w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-md shadow-sm text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">Manuell (kein Auto-Scan)</option>
|
||||
@foreach([5 => 'Alle 5 Minuten', 15 => 'Alle 15 Minuten', 30 => 'Alle 30 Minuten', 60 => 'Stündlich', 360 => 'Alle 6 Stunden', 720 => 'Alle 12 Stunden', 1440 => 'Täglich'] as $val => $label)
|
||||
<option value="{{ $val }}" {{ old('scan_interval_minutes', $segment->scan_interval_minutes) == $val ? 'selected' : '' }}>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="nmap_options" value="nmap-Parameter" />
|
||||
<x-text-input id="nmap_options" name="nmap_options" type="text" class="mt-1 block w-full font-mono"
|
||||
value="{{ old('nmap_options', $segment->nmap_options) }}" placeholder="-sn" />
|
||||
<p class="mt-1 text-xs text-gray-500">-sn = Ping-Scan · -sn -p 22,80,443 = mit Ports</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="active" name="active" value="1"
|
||||
{{ old('active', $segment->active) ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<label for="active" class="text-sm text-gray-700 dark:text-gray-300">Aktiv</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="{{ route('network.segments.index') }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 transition">
|
||||
Abbrechen
|
||||
</a>
|
||||
<button type="submit" style="background-color: var(--color-primary)"
|
||||
class="px-5 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('detect-subnet-btn').addEventListener('click', function() {
|
||||
const btn = this;
|
||||
const suggestions = document.getElementById('subnet-suggestions');
|
||||
btn.textContent = '⏳ Suche...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('{{ route('network.detect-subnets') }}', {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(function(subnets) {
|
||||
btn.textContent = '🔍 Erkennen';
|
||||
btn.disabled = false;
|
||||
if (!subnets.length) {
|
||||
suggestions.innerHTML = '<p class="text-xs text-gray-400">Keine Daten in der Datenbank gefunden.</p>';
|
||||
suggestions.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
suggestions.innerHTML = '<p class="text-xs text-gray-500 mb-1">Erkannte Subnetze – klicken zum Übernehmen:</p>' +
|
||||
subnets.map(function(s) {
|
||||
return '<button type="button" onclick="document.getElementById(\'subnet\').value=\'' + s + '\'" ' +
|
||||
'class="mr-1 mb-1 px-2 py-1 text-xs font-mono rounded border border-indigo-300 text-indigo-700 hover:bg-indigo-50 transition">' + s + '</button>';
|
||||
}).join('');
|
||||
suggestions.classList.remove('hidden');
|
||||
})
|
||||
.catch(function() {
|
||||
btn.textContent = '🔍 Erkennen';
|
||||
btn.disabled = false;
|
||||
suggestions.innerHTML = '<p class="text-xs text-red-500">Fehler bei der Erkennung.</p>';
|
||||
suggestions.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Segment {{ $segment->name }} – Export</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
color: #1f2937;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
h1 { font-size: 18px; font-weight: bold; color: #111827; }
|
||||
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.print-btn:hover { background: #4338ca; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
thead th {
|
||||
background-color: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 5px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) td { background-color: #f9fafb; }
|
||||
tbody tr:hover td { background-color: #eff6ff; }
|
||||
|
||||
.online { color: #16a34a; font-weight: 700; }
|
||||
.offline { color: #9ca3af; }
|
||||
.mono { font-family: 'Courier New', monospace; }
|
||||
.note { color: #4b5563; font-style: italic; }
|
||||
|
||||
.footer {
|
||||
margin-top: 12px;
|
||||
font-size: 9px;
|
||||
color: #9ca3af;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Print-Stile */
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
body { padding: 0; font-size: 10px; }
|
||||
thead th { background-color: #f3f4f6 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
tbody tr:nth-child(even) td { background-color: #f9fafb !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
@page { size: A4 landscape; margin: 1.5cm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🌐 Segment: {{ $segment->name }}</h1>
|
||||
<div class="meta">
|
||||
Subnetz: <strong>{{ $segment->subnet }}</strong>
|
||||
@if($segment->vlan_id) · 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>
|
||||
@@ -0,0 +1,77 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="{{ route('network.dashboard') }}" class="text-gray-500 hover:text-gray-700">Netzwerk</a>
|
||||
<span class="text-gray-400">/</span>
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">Segmente</h2>
|
||||
</div>
|
||||
<a href="{{ route('network.segments.create') }}" style="background-color: var(--color-primary)"
|
||||
class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
|
||||
+ Segment anlegen
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
|
||||
|
||||
@if(session('success'))
|
||||
<div class="mb-4 p-4 bg-green-100 text-green-800 rounded-md">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Aktiv</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Subnetz</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">VLAN</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Scans</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Beschreibung</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@forelse($segments as $segment)
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full {{ $segment->active ? 'bg-green-500' : 'bg-gray-300' }}"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
<a href="{{ route('network.segments.show', $segment) }}" class="hover:text-indigo-600">
|
||||
{{ $segment->name }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{{ $segment->subnet }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $segment->vlan_id ? 'VLAN ' . $segment->vlan_id : '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{{ $segment->scans_count }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 max-w-xs truncate">
|
||||
{{ $segment->description ?? '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right space-x-3">
|
||||
<a href="{{ route('network.segments.edit', $segment) }}"
|
||||
class="text-xs text-indigo-600 hover:underline">Bearbeiten</a>
|
||||
<form method="POST" action="{{ route('network.segments.destroy', $segment) }}"
|
||||
class="inline" onsubmit="return confirm('Segment löschen?')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="text-xs text-red-500 hover:underline">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-10 text-center text-gray-500">
|
||||
Noch keine Segmente. <a href="{{ route('network.segments.create') }}" class="text-indigo-600 hover:underline">Jetzt anlegen</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,174 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="{{ route('network.segments.index') }}" class="text-gray-500 hover:text-gray-700">Segmente</a>
|
||||
<span class="text-gray-400">/</span>
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200">{{ $segment->name }}</h2>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
{{-- Auto-Refresh Indikator --}}
|
||||
<span id="refresh-indicator" class="text-xs text-gray-400 dark:text-gray-500 hidden">
|
||||
↻ wird aktualisiert...
|
||||
</span>
|
||||
|
||||
{{-- Jetzt scannen --}}
|
||||
@if($segment->active)
|
||||
<form method="POST" action="{{ route('network.segments.scan', $segment) }}">
|
||||
@csrf
|
||||
<button type="submit" style="background-color: var(--color-primary)"
|
||||
class="px-4 py-2 text-white text-sm font-medium rounded-md hover:opacity-90 transition">
|
||||
▶ Jetzt scannen
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<a href="{{ route('network.import') }}?segment={{ $segment->id }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
⬆ Import
|
||||
</a>
|
||||
<a href="{{ route('network.segments.export.xlsx', $segment) }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
📊 Excel
|
||||
</a>
|
||||
<a href="{{ route('network.segments.export.pdf', $segment) }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
📄 PDF
|
||||
</a>
|
||||
<a href="{{ route('network.segments.edit', $segment) }}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
Bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
{{-- Segment-Info --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Subnetz</p>
|
||||
<p class="mt-1 font-mono font-bold text-gray-900 dark:text-gray-100">{{ $segment->subnet }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">VLAN</p>
|
||||
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100">{{ $segment->vlan_id ? 'VLAN ' . $segment->vlan_id : '—' }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</p>
|
||||
<p class="mt-1 font-bold {{ $segment->active ? 'text-green-600' : 'text-gray-400' }}">
|
||||
{{ $segment->active ? 'Aktiv' : 'Inaktiv' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Auto-Scan</p>
|
||||
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100 text-sm">
|
||||
{{ $segment->scan_interval_label }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Scans gesamt</p>
|
||||
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100">{{ $scans->total() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($latestScan)
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Letzter Scan</p>
|
||||
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100">{{ $latestScan->created_at->format('d.m.Y H:i') }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Online / Gesamt</p>
|
||||
<p class="mt-1 font-bold">
|
||||
<span class="text-green-600">{{ $latestScan->online_hosts }}</span>
|
||||
<span class="text-gray-400"> / {{ $latestScan->total_hosts }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Neue Geräte</p>
|
||||
<p class="mt-1 font-bold text-gray-900 dark:text-gray-100">{{ $latestScan->new_devices }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Scan-Historie --}}
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Scan-Historie</h3>
|
||||
</div>
|
||||
<div data-scan-count="{{ $scans->total() }}"></div>
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Datum</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Scanner</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Gesamt</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Online</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Neu</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Geändert</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
@forelse($scans as $scan)
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ $scan->created_at->format('d.m.Y H:i') }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{{ $scan->scanner ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{{ $scan->total_hosts }}</td>
|
||||
<td class="px-4 py-3 text-right text-green-600 font-medium">{{ $scan->online_hosts }}</td>
|
||||
<td class="px-4 py-3 text-right text-blue-600">{{ $scan->new_devices }}</td>
|
||||
<td class="px-4 py-3 text-right text-yellow-600">{{ $scan->changed_devices }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="{{ route('network.scan', $scan) }}"
|
||||
class="text-xs text-indigo-600 hover:underline">Details →</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-10 text-center text-gray-500">Noch keine Scans für dieses Segment.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
@if($scans->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
|
||||
{{ $scans->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session('success') && str_contains(session('success'), 'gestartet'))
|
||||
{{-- Auto-Refresh nach "Jetzt scannen": alle 10s prüfen ob neuer Scan vorliegt --}}
|
||||
<script>
|
||||
(function() {
|
||||
const currentScanCount = {{ $scans->total() }};
|
||||
const indicator = document.getElementById('refresh-indicator');
|
||||
let checks = 0;
|
||||
|
||||
const interval = setInterval(function() {
|
||||
if (++checks > 18) { clearInterval(interval); return; } // max 3 Min
|
||||
if (indicator) indicator.classList.remove('hidden');
|
||||
|
||||
fetch(window.location.href, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const newCount = parseInt(doc.querySelector('[data-scan-count]')?.dataset?.scanCount || '0');
|
||||
if (newCount > currentScanCount) {
|
||||
clearInterval(interval);
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 10000);
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
</x-app-layout>
|
||||
@@ -1,10 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
|
||||
class="{{ ($appSettings['theme_mode'] ?? 'light') === 'dark' ? 'dark' : '' }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>{{ config('app.name', 'Laravel') }}</title>
|
||||
<title>{{ $appSettings['site_name'] ?? config('app.name', 'Network-MGMT') }}</title>
|
||||
|
||||
@fonts
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\HelpController;
|
||||
use App\Http\Controllers\NetworkController;
|
||||
use App\Http\Controllers\NetworkSegmentController;
|
||||
use App\Http\Controllers\Admin\UserController as AdminUserController;
|
||||
use App\Http\Controllers\Admin\LayoutController as AdminLayoutController;
|
||||
use App\Http\Controllers\Admin\UpdateController as AdminUpdateController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
@@ -25,6 +30,68 @@ Route::prefix('admin')
|
||||
->group(function () {
|
||||
Route::get('/', fn() => redirect()->route('admin.users.index'));
|
||||
Route::resource('users', AdminUserController::class);
|
||||
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
|
||||
Route::prefix('network')
|
||||
->name('network.')
|
||||
->middleware(['auth'])
|
||||
->group(function () {
|
||||
// Dashboard
|
||||
Route::get('/', [NetworkController::class, 'dashboard'])->name('dashboard');
|
||||
|
||||
// Subnetz-Erkennung (JSON)
|
||||
Route::get('/detect-subnets', [NetworkController::class, 'detectSubnets'])->name('detect-subnets');
|
||||
|
||||
// Globale Suche
|
||||
Route::get('/search', [NetworkController::class, 'search'])->name('search');
|
||||
|
||||
// Segmente (CRUD + Scan auslösen)
|
||||
Route::resource('segments', NetworkSegmentController::class)
|
||||
->names('segments');
|
||||
Route::post('segments/{segment}/scan', [NetworkSegmentController::class, 'triggerScan'])
|
||||
->name('segments.scan');
|
||||
Route::post('segments/{segment}/ip-notes', [NetworkSegmentController::class, 'saveIpNote'])
|
||||
->name('segments.ip-notes');
|
||||
Route::get('segments/{segment}/export/xlsx', [NetworkSegmentController::class, 'exportXlsx'])
|
||||
->name('segments.export.xlsx');
|
||||
Route::get('segments/{segment}/export/pdf', [NetworkSegmentController::class, 'exportPdf'])
|
||||
->name('segments.export.pdf');
|
||||
|
||||
// Chronologischer IP-Verlauf
|
||||
Route::get('/history', [NetworkController::class, 'history'])->name('history');
|
||||
|
||||
// Geräte
|
||||
Route::get('/devices', [NetworkController::class, 'devices'])->name('devices');
|
||||
Route::get('/devices/{device}', [NetworkController::class, 'device'])->name('device');
|
||||
Route::put('/devices/{device}', [NetworkController::class, 'updateDevice'])->name('device.update');
|
||||
Route::post('/devices/{device}/note', [NetworkController::class, 'addNote'])->name('device.note');
|
||||
|
||||
// Ereignisse
|
||||
Route::post('/events/{event}/document', [NetworkController::class, 'documentEvent'])->name('document');
|
||||
|
||||
// Import
|
||||
Route::get('/import', [NetworkController::class, 'showImport'])->name('import');
|
||||
Route::post('/import', [NetworkController::class, 'import'])->name('import');
|
||||
|
||||
// Scan-Detail
|
||||
Route::get('/scans/{scan}', [NetworkController::class, 'scan'])->name('scan');
|
||||
});
|
||||
|
||||
// Hilfe-Bereich – für alle eingeloggten Benutzer
|
||||
Route::prefix('help')
|
||||
->name('help.')
|
||||
->middleware(['auth'])
|
||||
->group(function () {
|
||||
Route::get('manual', [HelpController::class, 'manual'])->name('manual');
|
||||
Route::get('changelog', [HelpController::class, 'changelog'])->name('changelog');
|
||||
});
|
||||
|
||||
require __DIR__.'/auth.php';
|
||||
|
||||
@@ -9,6 +9,8 @@ export default {
|
||||
'./resources/views/**/*.blade.php',
|
||||
],
|
||||
|
||||
darkMode: 'class',
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
Reference in New Issue
Block a user