initial commit

This commit is contained in:
TheOnlyMace
2026-05-17 13:26:14 +02:00
commit 75299b723d
176 changed files with 20327 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
@extends('layouts.app')
@section('title', 'System-Health')
@section('heading', 'System-Health')
@section('content')
<div class="grid gap-4 md:grid-cols-2">
@foreach($checks as $name => $check)
<div class="hexahost-card p-6">
<h3 class="mb-2 font-semibold capitalize">{{ str_replace('_', ' ', $name) }}</h3>
<span class="hexahost-badge-{{ $check['status'] === 'ok' ? 'success' : ($check['status'] === 'error' ? 'danger' : 'warning') }} rounded-full px-2 py-0.5 text-xs">{{ strtoupper($check['status']) }}</span>
<p class="mt-2 text-sm opacity-80">{{ $check['message'] }}</p>
</div>
@endforeach
</div>
@endsection

View File

@@ -0,0 +1,15 @@
@extends('layouts.app')
@section('title', 'Template anlegen')
@section('heading', 'VM-Template anlegen')
@section('content')
<form method="POST" action="{{ route('admin.templates.store') }}" class="hexahost-card max-w-lg space-y-4 p-6">
@csrf
<input name="slug" placeholder="slug" required class="hexahost-input w-full px-3 py-2">
<input name="name" placeholder="Anzeigename" required class="hexahost-input w-full px-3 py-2">
<input name="proxmox_template_vmid" type="number" placeholder="Proxmox Template VMID" required class="hexahost-input w-full px-3 py-2">
<input name="os_family" placeholder="OS (optional)" class="hexahost-input w-full px-3 py-2">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" value="1" checked> Aktiv</label>
<button class="hexahost-btn-primary px-4 py-2">Speichern</button>
</form>
@endsection

View File

@@ -0,0 +1,15 @@
@extends('layouts.app')
@section('title', 'Template bearbeiten')
@section('heading', 'Template bearbeiten')
@section('content')
<form method="POST" action="{{ route('admin.templates.update', $template) }}" class="hexahost-card max-w-lg space-y-4 p-6">
@csrf @method('PUT')
<input name="slug" value="{{ $template->slug }}" required class="hexahost-input w-full px-3 py-2">
<input name="name" value="{{ $template->name }}" required class="hexahost-input w-full px-3 py-2">
<input name="proxmox_template_vmid" type="number" value="{{ $template->proxmox_template_vmid }}" required class="hexahost-input w-full px-3 py-2">
<input name="os_family" value="{{ $template->os_family }}" class="hexahost-input w-full px-3 py-2">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_active" value="1" {{ $template->is_active ? 'checked' : '' }}> Aktiv</label>
<button class="hexahost-btn-primary px-4 py-2">Aktualisieren</button>
</form>
@endsection

View File

@@ -0,0 +1,25 @@
@extends('layouts.app')
@section('title', 'VM-Templates')
@section('heading', 'VM-Templates')
@section('content')
<a href="{{ route('admin.templates.create') }}" class="hexahost-btn-primary mb-4 inline-block px-4 py-2 text-sm">Neues Template</a>
<div class="hexahost-card overflow-hidden">
<table class="w-full text-sm">
<thead class="border-b border-white/10 text-left opacity-70"><tr><th class="p-3">Name</th><th>Slug</th><th>VMID</th><th></th></tr></thead>
<tbody>
@foreach($templates as $t)
<tr class="border-b border-white/5">
<td class="p-3">{{ $t->name }}</td>
<td class="font-mono">{{ $t->slug }}</td>
<td>{{ $t->proxmox_template_vmid }}</td>
<td class="p-3 text-right">
<a href="{{ route('admin.templates.edit', $t) }}" class="text-cyan-400 hover:underline">Bearbeiten</a>
<form method="POST" action="{{ route('admin.templates.destroy', $t) }}" class="inline">@csrf @method('DELETE')<button class="ml-2 text-red-400">Löschen</button></form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endsection

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden HexaHost Panel</title>
@vite(['resources/css/app.css'])
</head>
<body class="flex min-h-screen items-center justify-center bg-slate-950 px-4 text-slate-100">
<div class="w-full max-w-md">
<div class="mb-8 text-center">
<h1 class="text-2xl font-bold text-cyan-400">HexaHost Panel</h1>
<p class="mt-2 text-sm text-slate-400">Hosting Control Panel</p>
</div>
<form method="POST" action="{{ route('login') }}" class="rounded-xl border border-slate-800 bg-slate-900 p-8 shadow-xl">
@csrf
@if($errors->any())
<div class="mb-4 rounded-lg bg-red-950/50 px-3 py-2 text-sm text-red-300">{{ $errors->first() }}</div>
@endif
<label class="mb-4 block">
<span class="text-sm text-slate-400">E-Mail</span>
<input type="email" name="email" value="{{ old('email') }}" required autofocus
class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500">
</label>
<label class="mb-4 block">
<span class="text-sm text-slate-400">Passwort</span>
<input type="password" name="password" required
class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500">
</label>
<label class="mb-6 flex items-center gap-2 text-sm text-slate-400">
<input type="checkbox" name="remember" class="rounded border-slate-600 bg-slate-800 text-cyan-500">
Angemeldet bleiben
</label>
<button type="submit" class="w-full rounded-lg bg-cyan-600 py-2.5 font-medium text-white hover:bg-cyan-500 transition">
Anmelden
</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,14 @@
@extends('layouts.app')
@section('title', '2FA')
@section('heading', 'Authentifizierung')
@section('content')
<div class="hexahost-card mx-auto max-w-md p-6">
<form method="POST" action="{{ route('two-factor.challenge.store') }}" class="space-y-4">
@csrf
<label class="block text-sm opacity-80">Code aus Authenticator-App</label>
<input type="text" name="code" inputmode="numeric" required class="hexahost-input w-full px-3 py-2">
<button class="hexahost-btn-primary w-full px-4 py-2">Bestätigen</button>
</form>
</div>
@endsection

View File

@@ -0,0 +1,16 @@
@extends('layouts.app')
@section('title', '2FA einrichten')
@section('heading', 'Zwei-Faktor-Authentifizierung')
@section('content')
<div class="hexahost-card mx-auto max-w-lg p-6">
<p class="mb-4 text-sm opacity-80">Admins müssen 2FA aktivieren. Scannen Sie den QR-Code mit Ihrer Authenticator-App.</p>
<div class="mb-4 flex justify-center">{!! $qrSvg !!}</div>
<p class="mb-4 font-mono text-center text-sm">{{ $secret }}</p>
<form method="POST" action="{{ route('two-factor.enable') }}" class="space-y-4">
@csrf
<input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required placeholder="6-stelliger Code" class="hexahost-input w-full px-3 py-2">
<button class="hexahost-btn-primary w-full px-4 py-2">Aktivieren</button>
</form>
</div>
@endsection

View File

@@ -0,0 +1,69 @@
@extends('layouts.app')
@section('title', 'Dashboard')
@section('heading', 'Dashboard')
@section('content')
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
@foreach([
['label' => 'VMs gesamt', 'value' => $stats['vms_total'], 'color' => 'text-white'],
['label' => 'Aktiv', 'value' => $stats['vms_active'], 'color' => 'text-emerald-400'],
['label' => 'In Bereitstellung', 'value' => $stats['vms_pending'], 'color' => 'text-amber-400'],
['label' => 'Fehlgeschlagen', 'value' => $stats['vms_failed'], 'color' => 'text-red-400'],
] as $card)
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-5">
<p class="text-sm text-slate-400">{{ $card['label'] }}</p>
<p class="mt-2 text-3xl font-bold {{ $card['color'] }}">{{ $card['value'] }}</p>
</div>
@endforeach
</div>
@if($usersCount !== null)
<div class="mt-4 rounded-xl border border-slate-800 bg-slate-900/60 p-4 text-sm text-slate-400">
Registrierte Benutzer: <span class="font-medium text-white">{{ $usersCount }}</span>
</div>
@endif
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<h2 class="mb-4 text-lg font-semibold">IP-Pools</h2>
<div class="space-y-3">
@forelse($pools as $pool)
<div>
<div class="flex justify-between text-sm">
<span>{{ $pool->name }} <span class="text-slate-500">({{ $pool->type->label() }})</span></span>
<span class="text-slate-400">{{ $pool->usedIpsCount() }}/{{ $pool->totalIps() }}</span>
</div>
<div class="mt-1 h-2 overflow-hidden rounded-full bg-slate-800">
@php $pct = $pool->totalIps() > 0 ? ($pool->usedIpsCount() / $pool->totalIps()) * 100 : 0; @endphp
<div class="h-full rounded-full {{ $pool->type->value === 'public' ? 'bg-violet-500' : 'bg-cyan-500' }}" style="width: {{ min(100, $pct) }}%"></div>
</div>
</div>
@empty
<p class="text-sm text-slate-500">Keine IP-Pools konfiguriert.</p>
@endforelse
</div>
<a href="{{ route('ip-pools.index') }}" class="mt-4 inline-block text-sm text-cyan-400 hover:underline">Alle Pools anzeigen </a>
</section>
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Letzte VMs</h2>
<a href="{{ route('vms.create') }}" class="rounded-lg bg-cyan-600 px-3 py-1.5 text-sm font-medium hover:bg-cyan-500">+ Neue VM</a>
</div>
<div class="divide-y divide-slate-800">
@forelse($recentVms as $vm)
<a href="{{ route('vms.show', $vm) }}" class="flex items-center justify-between py-3 hover:bg-slate-800/30 -mx-2 px-2 rounded-lg transition">
<div>
<p class="font-medium">{{ $vm->name }}</p>
<p class="text-xs text-slate-500">{{ $vm->domain ?? 'Kein Traefik' }} · {{ $vm->ip_address ?? '—' }}</p>
</div>
@include('partials.status-badge', ['status' => $vm->status])
</a>
@empty
<p class="py-4 text-sm text-slate-500">Noch keine VMs.</p>
@endforelse
</div>
</section>
</div>
@endsection

View File

@@ -0,0 +1,31 @@
@extends('layouts.app')
@section('title', 'IP-Pool')
@section('heading', 'Neuer IP-Pool')
@section('content')
<form method="POST" action="{{ route('ip-pools.store') }}" class="max-w-lg space-y-4 rounded-xl border border-slate-800 bg-slate-900/60 p-6">
@csrf
<label class="block"><span class="text-sm text-slate-400">Name</span>
<input name="name" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="block"><span class="text-sm text-slate-400">Typ</span>
<select name="type" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2">
@foreach($types as $type)<option value="{{ $type->value }}">{{ $type->label() }}</option>@endforeach
</select></label>
<div class="grid grid-cols-2 gap-4">
<label class="block"><span class="text-sm text-slate-400">Start-IP</span>
<input name="start_ip" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 font-mono"></label>
<label class="block"><span class="text-sm text-slate-400">End-IP</span>
<input name="end_ip" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 font-mono"></label>
</div>
<label class="block"><span class="text-sm text-slate-400">Gateway</span>
<input name="gateway" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 font-mono"></label>
<label class="block"><span class="text-sm text-slate-400">CIDR</span>
<input type="number" name="cidr" value="24" min="8" max="32" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="block"><span class="text-sm text-slate-400">Beschreibung</span>
<textarea name="description" rows="2" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></textarea></label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="is_active" value="1" checked class="rounded text-cyan-500"> Aktiv
</label>
<button class="rounded-lg bg-cyan-600 px-6 py-2 font-medium hover:bg-cyan-500">Erstellen</button>
</form>
@endsection

View File

@@ -0,0 +1,35 @@
@extends('layouts.app')
@section('title', 'IP-Pool')
@section('heading', 'Pool bearbeiten')
@section('content')
<form method="POST" action="{{ route('ip-pools.update', $pool) }}" class="max-w-lg space-y-4 rounded-xl border border-slate-800 bg-slate-900/60 p-6">
@csrf @method('PUT')
<label class="block"><span class="text-sm text-slate-400">Name</span>
<input name="name" value="{{ old('name', $pool->name) }}" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="block"><span class="text-sm text-slate-400">Typ</span>
<select name="type" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2">
@foreach($types as $type)
<option value="{{ $type->value }}" @selected(old('type', $pool->type->value) === $type->value)>{{ $type->label() }}</option>
@endforeach
</select></label>
<div class="grid grid-cols-2 gap-4">
<label class="block"><span class="text-sm text-slate-400">Start-IP</span>
<input name="start_ip" value="{{ old('start_ip', $pool->start_ip) }}" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 font-mono"></label>
<label class="block"><span class="text-sm text-slate-400">End-IP</span>
<input name="end_ip" value="{{ old('end_ip', $pool->end_ip) }}" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 font-mono"></label>
</div>
<label class="block"><span class="text-sm text-slate-400">Gateway</span>
<input name="gateway" value="{{ old('gateway', $pool->gateway) }}" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 font-mono"></label>
<label class="block"><span class="text-sm text-slate-400">CIDR</span>
<input type="number" name="cidr" value="{{ old('cidr', $pool->cidr) }}" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $pool->is_active)) class="rounded text-cyan-500"> Aktiv
</label>
<button class="rounded-lg bg-cyan-600 px-6 py-2 font-medium hover:bg-cyan-500">Speichern</button>
</form>
<form method="POST" action="{{ route('ip-pools.destroy', $pool) }}" class="mt-4" onsubmit="return confirm('Pool löschen?')">
@csrf @method('DELETE')
<button class="rounded-lg border border-red-800 px-4 py-2 text-sm text-red-400">Pool löschen</button>
</form>
@endsection

View File

@@ -0,0 +1,64 @@
@extends('layouts.app')
@section('title', 'IP-Pools')
@section('heading', 'IP-Pools & Zuweisungen')
@section('content')
@if(auth()->user()->isAdmin())
<a href="{{ route('ip-pools.create') }}" class="mb-6 inline-block rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium hover:bg-cyan-500">+ Pool anlegen</a>
@endif
<div class="mb-8 grid gap-4 md:grid-cols-2">
@foreach($pools as $pool)
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-5">
<div class="flex items-start justify-between">
<div>
<h3 class="font-semibold">{{ $pool->name }}</h3>
<span class="text-xs {{ $pool->type->value === 'public' ? 'text-violet-400' : 'text-cyan-400' }}">{{ $pool->type->label() }}</span>
</div>
@if(auth()->user()->isAdmin())
<a href="{{ route('ip-pools.edit', $pool) }}" class="text-xs text-slate-400 hover:text-white">Bearbeiten</a>
@endif
</div>
<p class="mt-2 font-mono text-sm text-slate-400">{{ $pool->start_ip }} {{ $pool->end_ip }}</p>
<p class="mt-1 text-xs text-slate-500">Gateway: {{ $pool->gateway ?? '—' }} /{{ $pool->cidr }}</p>
<div class="mt-3">
<div class="flex justify-between text-xs text-slate-400 mb-1">
<span>{{ $pool->usedIpsCount() }} belegt</span>
<span>{{ $pool->freeIpsCount() }} frei</span>
</div>
<div class="h-2 rounded-full bg-slate-800">
<div class="h-full rounded-full {{ $pool->type->value === 'public' ? 'bg-violet-500' : 'bg-cyan-500' }}" style="width: {{ $pool->usage_percent }}%"></div>
</div>
</div>
</div>
@endforeach
</div>
<h2 class="mb-4 text-lg font-semibold">IP-Zuweisungen (aktive VMs)</h2>
<div class="overflow-hidden rounded-xl border border-slate-800">
<table class="w-full text-left text-sm">
<thead class="bg-slate-900 text-slate-400">
<tr>
<th class="px-4 py-3">VM</th>
@if(auth()->user()->isAdmin())<th class="px-4 py-3">Kunde</th>@endif
<th class="px-4 py-3">Private IP</th>
<th class="px-4 py-3">Public IP</th>
<th class="px-4 py-3">Pool</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
@forelse($assignments as $vm)
<tr>
<td class="px-4 py-3"><a href="{{ route('vms.show', $vm) }}" class="text-cyan-400 hover:underline">{{ $vm->name }}</a></td>
@if(auth()->user()->isAdmin())<td class="px-4 py-3 text-slate-400">{{ $vm->owner?->name }}</td>@endif
<td class="px-4 py-3 font-mono">{{ $vm->ip_address ?? '—' }}</td>
<td class="px-4 py-3 font-mono text-violet-400">{{ $vm->public_ip ?? '—' }}</td>
<td class="px-4 py-3">{{ $vm->ipPool?->name ?? '—' }}</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">Keine Zuweisungen.</td></tr>
@endforelse
</tbody>
</table>
</div>
@endsection

View File

@@ -0,0 +1,24 @@
@extends('layouts.app')
@section('title', 'ISO-Upload')
@section('heading', 'Eigene ISO hochladen')
@section('content')
<div class="hexahost-card mb-6 max-w-xl p-6">
<p class="mb-4 text-sm opacity-80">Max. {{ config('hosting.iso_upload.max_per_customer') }} ISO, {{ config('hosting.iso_upload.max_size_mb') }} MB, {{ config('hosting.iso_upload.retention_hours') }}h Aufbewahrung.</p>
<form method="POST" action="{{ route('iso-uploads.store') }}" enctype="multipart/form-data" class="space-y-4">
@csrf
<input type="file" name="iso" accept=".iso" required class="w-full text-sm">
<button class="hexahost-btn-primary px-4 py-2 text-sm">Hochladen</button>
</form>
</div>
<div class="hexahost-card p-6">
<h2 class="mb-4 font-semibold">Aktive Uploads</h2>
<ul class="space-y-2 text-sm">
@forelse($uploads as $u)
<li>{{ $u->filename }} läuft ab {{ $u->expires_at->format('d.m.Y H:i') }}</li>
@empty
<li class="opacity-60">Keine Uploads.</li>
@endforelse
</ul>
</div>
@endsection

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', 'Dashboard') {{ config('app.name', 'HexaHost Panel') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="hexahost-panel min-h-screen antialiased">
<div class="flex min-h-screen">
<aside class="hexahost-sidebar hidden w-64 shrink-0 lg:block">
<div class="flex h-16 items-center border-b border-white/10 px-6">
<span class="hexahost-brand text-lg">HexaHost</span>
</div>
<nav class="space-y-1 p-4">
<a href="{{ route('dashboard') }}" class="hexahost-nav-link {{ request()->routeIs('dashboard') ? 'active' : '' }} flex items-center gap-3 px-3 py-2 text-sm font-medium">Dashboard</a>
<a href="{{ route('vms.index') }}" class="hexahost-nav-link {{ request()->routeIs('vms.*') ? 'active' : '' }} flex items-center gap-3 px-3 py-2 text-sm font-medium">Virtuelle Maschinen</a>
<a href="{{ route('iso-uploads.index') }}" class="hexahost-nav-link {{ request()->routeIs('iso-uploads.*') ? 'active' : '' }} flex items-center gap-3 px-3 py-2 text-sm font-medium">ISO-Upload</a>
<a href="{{ route('ip-pools.index') }}" class="hexahost-nav-link {{ request()->routeIs('ip-pools.*') ? 'active' : '' }} flex items-center gap-3 px-3 py-2 text-sm font-medium">IP-Pools</a>
@if(auth()->user()->isAdmin())
<a href="{{ route('users.index') }}" class="hexahost-nav-link {{ request()->routeIs('users.*') ? 'active' : '' }} flex items-center gap-3 px-3 py-2 text-sm font-medium">Benutzer</a>
<a href="{{ route('admin.templates.index') }}" class="hexahost-nav-link {{ request()->routeIs('admin.templates.*') ? 'active' : '' }} flex items-center gap-3 px-3 py-2 text-sm font-medium">VM-Templates</a>
<a href="{{ route('admin.health') }}" class="hexahost-nav-link {{ request()->routeIs('admin.health') ? 'active' : '' }} flex items-center gap-3 px-3 py-2 text-sm font-medium">System-Health</a>
@endif
</nav>
</aside>
<div class="flex flex-1 flex-col">
<header class="hexahost-header flex h-16 items-center justify-between px-6">
<h1 class="text-lg font-semibold">@yield('heading', 'Dashboard')</h1>
<div class="flex items-center gap-4">
<span class="hidden text-sm opacity-70 sm:inline">{{ auth()->user()->name }}</span>
<span class="rounded-full border border-white/20 px-2.5 py-0.5 text-xs font-medium" style="color: var(--hx-primary)">{{ auth()->user()->role->label() }}</span>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-sm opacity-70 hover:opacity-100">Abmelden</button>
</form>
</div>
</header>
<main class="flex-1 p-6">
@if(session('success'))
<div class="hexahost-card mb-4 border border-emerald-500/30 px-4 py-3 text-sm text-emerald-300">{{ session('success') }}</div>
@endif
@if(session('warning'))
<div class="hexahost-card mb-4 border border-amber-500/30 px-4 py-3 text-sm text-amber-300">{{ session('warning') }}</div>
@endif
@if($errors->any())
<div class="hexahost-card mb-4 border border-red-500/30 px-4 py-3 text-sm text-red-300">
<ul class="list-inside list-disc space-y-1">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@yield('content')
</main>
</div>
</div>
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,12 @@
@php
$classes = match($status) {
'active' => 'bg-emerald-950 text-emerald-400 border-emerald-800',
'pending' => 'bg-amber-950 text-amber-400 border-amber-800',
'failed' => 'bg-red-950 text-red-400 border-red-800',
default => 'bg-slate-800 text-slate-400 border-slate-700',
};
$labels = ['active' => 'Aktiv', 'pending' => 'Ausstehend', 'failed' => 'Fehler'];
@endphp
<span class="inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium {{ $classes }}">
{{ $labels[$status] ?? $status }}
</span>

View File

@@ -0,0 +1,28 @@
<div id="devices-container" class="space-y-3">
@php $devices = old('devices', $devices ?? []); @endphp
@forelse($devices as $i => $device)
@include('partials.vm-device-row', ['index' => $i, 'device' => $device, 'deviceTypes' => $deviceTypes])
@empty
@endforelse
</div>
<button type="button" id="add-device" class="mt-2 text-sm text-cyan-400 hover:underline">+ Gerät hinzufügen</button>
<template id="device-row-template">
@include('partials.vm-device-row', ['index' => '__INDEX__', 'device' => [], 'deviceTypes' => $deviceTypes])
</template>
@push('scripts')
<script>
document.getElementById('add-device')?.addEventListener('click', () => {
const container = document.getElementById('devices-container');
const index = container.children.length;
const tpl = document.getElementById('device-row-template').innerHTML.replace(/__INDEX__/g, index);
container.insertAdjacentHTML('beforeend', tpl);
});
document.getElementById('devices-container')?.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-device')) {
e.target.closest('.device-row')?.remove();
}
});
</script>
@endpush

View File

@@ -0,0 +1,25 @@
<div class="device-row rounded-lg border border-slate-700 bg-slate-800/50 p-4">
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-medium text-slate-300">Zusatzgerät</span>
<button type="button" class="remove-device text-xs text-red-400 hover:underline">Entfernen</button>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<label>
<span class="text-xs text-slate-500">Typ</span>
<select name="devices[{{ $index }}][type]" class="mt-1 w-full rounded border border-slate-600 bg-slate-800 px-2 py-1.5 text-sm">
<option value=""> wählen </option>
@foreach($deviceTypes as $value => $label)
<option value="{{ $value }}" @selected(($device['type'] ?? '') === $value)>{{ $label }}</option>
@endforeach
</select>
</label>
<label>
<span class="text-xs text-slate-500">Slot (z.B. scsi1)</span>
<input name="devices[{{ $index }}][slot]" value="{{ $device['slot'] ?? '' }}" class="mt-1 w-full rounded border border-slate-600 bg-slate-800 px-2 py-1.5 text-sm">
</label>
<label>
<span class="text-xs text-slate-500">Konfiguration (JSON-Felder)</span>
<input name="devices[{{ $index }}][config][size_gb]" placeholder="size_gb (Disk)" value="{{ $device['config']['size_gb'] ?? '' }}" class="mt-1 w-full rounded border border-slate-600 bg-slate-800 px-2 py-1.5 text-sm">
</label>
</div>
</div>

View File

@@ -0,0 +1,25 @@
@extends('layouts.app')
@section('title', 'Benutzer')
@section('heading', 'Neuer Benutzer')
@section('content')
<form method="POST" action="{{ route('users.store') }}" class="max-w-md space-y-4 rounded-xl border border-slate-800 bg-slate-900/60 p-6">
@csrf
<label class="block"><span class="text-sm text-slate-400">Name</span>
<input name="name" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="block"><span class="text-sm text-slate-400">E-Mail</span>
<input type="email" name="email" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="block"><span class="text-sm text-slate-400">Passwort</span>
<input type="password" name="password" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="block"><span class="text-sm text-slate-400">Passwort bestätigen</span>
<input type="password" name="password_confirmation" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="block"><span class="text-sm text-slate-400">Rolle</span>
<select name="role" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2">
@foreach($roles as $role)<option value="{{ $role->value }}">{{ $role->label() }}</option>@endforeach
</select></label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="is_active" value="1" checked class="rounded text-cyan-500"> Aktiv
</label>
<button class="rounded-lg bg-cyan-600 px-6 py-2 font-medium hover:bg-cyan-500">Erstellen</button>
</form>
@endsection

View File

@@ -0,0 +1,33 @@
@extends('layouts.app')
@section('title', 'Benutzer')
@section('heading', 'Benutzer bearbeiten')
@section('content')
<form method="POST" action="{{ route('users.update', $user) }}" class="max-w-md space-y-4 rounded-xl border border-slate-800 bg-slate-900/60 p-6">
@csrf @method('PUT')
<label class="block"><span class="text-sm text-slate-400">Name</span>
<input name="name" value="{{ old('name', $user->name) }}" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="block"><span class="text-sm text-slate-400">E-Mail</span>
<input type="email" name="email" value="{{ old('email', $user->email) }}" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="block"><span class="text-sm text-slate-400">Neues Passwort (optional)</span>
<input type="password" name="password" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="block"><span class="text-sm text-slate-400">Passwort bestätigen</span>
<input type="password" name="password_confirmation" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label class="block"><span class="text-sm text-slate-400">Rolle</span>
<select name="role" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2">
@foreach($roles as $role)
<option value="{{ $role->value }}" @selected(old('role', $user->role->value) === $role->value)>{{ $role->label() }}</option>
@endforeach
</select></label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $user->is_active)) class="rounded text-cyan-500"> Aktiv
</label>
<button class="rounded-lg bg-cyan-600 px-6 py-2 font-medium hover:bg-cyan-500">Speichern</button>
</form>
@if(auth()->id() !== $user->id)
<form method="POST" action="{{ route('users.destroy', $user) }}" class="mt-4" onsubmit="return confirm('Benutzer löschen?')">
@csrf @method('DELETE')
<button class="rounded-lg border border-red-800 px-4 py-2 text-sm text-red-400">Benutzer löschen</button>
</form>
@endif
@endsection

View File

@@ -0,0 +1,37 @@
@extends('layouts.app')
@section('title', 'Benutzer')
@section('heading', 'Benutzerverwaltung')
@section('content')
<a href="{{ route('users.create') }}" class="mb-6 inline-block rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium hover:bg-cyan-500">+ Benutzer</a>
<div class="overflow-hidden rounded-xl border border-slate-800">
<table class="w-full text-left text-sm">
<thead class="bg-slate-900 text-slate-400">
<tr>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">E-Mail</th>
<th class="px-4 py-3">Rolle</th>
<th class="px-4 py-3">VMs</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
@foreach($users as $user)
<tr>
<td class="px-4 py-3 font-medium">{{ $user->name }}</td>
<td class="px-4 py-3 text-slate-400">{{ $user->email }}</td>
<td class="px-4 py-3">{{ $user->role->label() }}</td>
<td class="px-4 py-3">{{ $user->vms_count }}</td>
<td class="px-4 py-3">{{ $user->is_active ? 'Aktiv' : 'Deaktiviert' }}</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('users.edit', $user) }}" class="text-cyan-400 hover:underline">Bearbeiten</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{ $users->links() }}
@endsection

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Konsole Fehler</title>
@vite(['resources/css/app.css'])
</head>
<body class="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100">
<div class="max-w-md rounded-xl border border-red-800 bg-slate-900 p-8 text-center">
<h1 class="text-lg font-semibold text-red-400">Konsole nicht verfügbar</h1>
<p class="mt-4 text-sm text-slate-400">{{ $error }}</p>
<a href="{{ route('vms.show', $vm) }}" class="mt-6 inline-block text-cyan-400 hover:underline"> Zurück</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Konsole {{ $vm->name }}</title>
@vite(['resources/css/app.css'])
<style>
html, body { height: 100%; margin: 0; background: #0f172a; }
#screen { width: 100%; height: calc(100vh - 48px); }
.toolbar { height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 1rem; background: #1e293b; color: #e2e8f0; font-family: system-ui, sans-serif; font-size: 14px; }
.toolbar a { color: #22d3ee; text-decoration: none; }
#status { color: #94a3b8; }
</style>
</head>
<body>
<div class="toolbar">
<span><strong>{{ $vm->displayName() }}</strong> noVNC</span>
<span id="status">Verbinde…</span>
<a href="{{ route('vms.show', $vm) }}"> Zurück zur VM</a>
</div>
<div id="screen"></div>
<script type="module">
import RFB from 'https://cdn.jsdelivr.net/npm/@novnc/novnc@1.5.0/core/rfb.js';
const wsUrl = @json($wsUrl);
const statusEl = document.getElementById('status');
const screen = document.getElementById('screen');
try {
const rfb = new RFB(screen, wsUrl, {
wsProtocols: ['binary'],
});
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener('connect', () => { statusEl.textContent = 'Verbunden'; statusEl.style.color = '#34d399'; });
rfb.addEventListener('disconnect', (e) => {
statusEl.textContent = e.detail.clean ? 'Getrennt' : 'Verbindung verloren';
statusEl.style.color = '#f87171';
});
rfb.addEventListener('credentialsrequired', () => { statusEl.textContent = 'Anmeldung erforderlich'; });
window.addEventListener('beforeunload', () => rfb.disconnect());
} catch (err) {
statusEl.textContent = 'Fehler: ' + err.message;
statusEl.style.color = '#f87171';
}
</script>
<p style="position:fixed;bottom:8px;left:8px;font-size:11px;color:#64748b;max-width:400px">
Hinweis: Der Browser muss Proxmox unter {{ parse_url(config('hosting.proxmox.console_ws_url') ?: config('hosting.proxmox.url'), PHP_URL_HOST) }} erreichen können.
</p>
</body>
</html>

View File

@@ -0,0 +1,98 @@
@extends('layouts.app')
@section('title', 'VM erstellen')
@section('heading', 'Neue VM erstellen')
@section('content')
<form method="POST" action="{{ route('vms.store') }}" class="max-w-3xl space-y-6">
@csrf
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6 space-y-4">
<h2 class="font-semibold">Allgemein</h2>
<label class="block">
<span class="text-sm text-slate-400">VM-Name</span>
<input name="name" value="{{ old('name') }}" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2">
</label>
@if($customers->isNotEmpty())
<label class="block">
<span class="text-sm text-slate-400">Zugewiesener Kunde</span>
<select name="user_id" required class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2">
@foreach($customers as $c)
<option value="{{ $c->id }}" @selected(old('user_id') == $c->id)>{{ $c->name }} ({{ $c->email }})</option>
@endforeach
</select>
</label>
@endif
<label class="flex items-center gap-2">
<input type="hidden" name="behind_traefik" value="0">
<input type="checkbox" name="behind_traefik" value="1" @checked(old('behind_traefik', true)) id="behind_traefik" class="rounded border-slate-600 text-cyan-500">
<span class="text-sm">Hinter Traefik (Subdomain + DNS)</span>
</label>
<div id="traefik-fields">
<label class="block">
<span class="text-sm text-slate-400">Subdomain</span>
<div class="mt-1 flex">
<input name="subdomain" value="{{ old('subdomain') }}" class="w-full rounded-l-lg border border-slate-700 bg-slate-800 px-3 py-2">
<span class="flex items-center rounded-r-lg border border-l-0 border-slate-700 bg-slate-800 px-3 text-sm text-slate-500">.{{ config('hosting.plesk.base_domain') }}</span>
</div>
</label>
</div>
<label class="block">
<span class="text-sm text-slate-400">Privater IP-Pool</span>
<select name="ip_pool_id" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2">
<option value="">Standard-Pool</option>
@foreach($privatePools as $pool)
<option value="{{ $pool->id }}" @selected(old('ip_pool_id') == $pool->id)>{{ $pool->name }} ({{ $pool->freeIpsCount() }} frei)</option>
@endforeach
</select>
</label>
<p class="text-xs text-slate-500">Ohne Traefik wird zusätzlich eine öffentliche IP aus dem Public-Pool vergeben.</p>
</section>
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6 space-y-4">
<h2 class="font-semibold">Ressourcen</h2>
<div class="grid gap-4 sm:grid-cols-3">
<label><span class="text-sm text-slate-400">vCPUs</span>
<input type="number" name="cpu" value="{{ old('cpu', config('hosting.defaults.cpu')) }}" min="1" max="32" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label><span class="text-sm text-slate-400">RAM (MB)</span>
<input type="number" name="ram" value="{{ old('ram', config('hosting.defaults.ram')) }}" min="512" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label><span class="text-sm text-slate-400">Disk (GB)</span>
<input type="number" name="disk" value="{{ old('disk', config('hosting.defaults.disk')) }}" min="10" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
</div>
</section>
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6 space-y-4">
<h2 class="font-semibold">Installations-ISO (optional)</h2>
<p class="text-xs text-slate-500">ISO wird beim Provisioning als CD-ROM eingebunden (Boot-Reihenfolge: CD zuerst).</p>
<select name="install_iso" class="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-sm">
<option value="">Keine ISO / Cloud-Init</option>
@foreach($isos as $iso)
<option value="{{ $iso['volid'] }}" @selected(old('install_iso') === $iso['volid'])>{{ $iso['label'] }}</option>
@endforeach
</select>
@if(empty($isos))
<p class="text-xs text-amber-400">Keine ISOs von Proxmox geladen Storage {{ config('hosting.proxmox.iso_storage') }} prüfen.</p>
@endif
</section>
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<h2 class="mb-4 font-semibold">Zusätzliche Geräte</h2>
@include('partials.vm-device-fields', ['deviceTypes' => $deviceTypes])
</section>
<button type="submit" class="rounded-lg bg-cyan-600 px-6 py-2.5 font-medium hover:bg-cyan-500">VM provisionieren</button>
</form>
@push('scripts')
<script>
const cb = document.getElementById('behind_traefik');
const fields = document.getElementById('traefik-fields');
function toggle() { fields.style.display = cb.checked ? 'block' : 'none'; }
cb?.addEventListener('change', toggle);
toggle();
</script>
@endpush
@endsection

View File

@@ -0,0 +1,33 @@
@extends('layouts.app')
@section('title', 'VM bearbeiten')
@section('heading', 'VM konfigurieren: '.$vm->name)
@section('content')
<form method="POST" action="{{ route('vms.update', $vm) }}" class="max-w-3xl space-y-6">
@csrf @method('PUT')
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6 space-y-4">
<label class="block">
<span class="text-sm text-slate-400">Name</span>
<input name="name" value="{{ old('name', $vm->name) }}" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2">
</label>
<div class="grid gap-4 sm:grid-cols-3">
<label><span class="text-sm text-slate-400">vCPUs</span>
<input type="number" name="cpu" value="{{ old('cpu', $vm->cpu) }}" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label><span class="text-sm text-slate-400">RAM (MB)</span>
<input type="number" name="ram" value="{{ old('ram', $vm->ram) }}" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
<label><span class="text-sm text-slate-400">Disk (GB)</span>
<input type="number" name="disk" value="{{ old('disk', $vm->disk) }}" class="mt-1 w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-2"></label>
</div>
<p class="text-xs text-slate-500">Änderungen werden bei aktiver VM an Proxmox übertragen.</p>
</section>
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<h2 class="mb-4 font-semibold">Zusätzliche Geräte</h2>
@php $devices = old('devices', $vm->devices->map(fn($d) => ['type'=>$d->type,'slot'=>$d->slot,'config'=>$d->config])->all()); @endphp
@include('partials.vm-device-fields', ['deviceTypes' => $deviceTypes, 'devices' => $devices])
</section>
<button type="submit" class="rounded-lg bg-cyan-600 px-6 py-2.5 font-medium hover:bg-cyan-500">Speichern</button>
</form>
@endsection

View File

@@ -0,0 +1,61 @@
@extends('layouts.app')
@section('title', 'VMs')
@section('heading', 'Virtuelle Maschinen')
@section('content')
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<form method="GET" class="flex gap-2">
<select name="status" onchange="this.form.submit()" class="rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-sm">
<option value="">Alle Status</option>
@foreach(['pending','active','failed'] as $s)
<option value="{{ $s }}" @selected(request('status') === $s)>{{ ucfirst($s) }}</option>
@endforeach
</select>
</form>
<a href="{{ route('vms.create') }}" class="rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium hover:bg-cyan-500">+ VM erstellen</a>
</div>
<div class="overflow-hidden rounded-xl border border-slate-800">
<table class="w-full text-left text-sm">
<thead class="bg-slate-900 text-slate-400">
<tr>
<th class="px-4 py-3">Name</th>
@if(auth()->user()->isAdmin())<th class="px-4 py-3">Kunde</th>@endif
<th class="px-4 py-3">VMID</th>
<th class="px-4 py-3">IP / Public</th>
<th class="px-4 py-3">Ressourcen</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800 bg-slate-900/40">
@forelse($vms as $vm)
<tr class="hover:bg-slate-800/30">
<td class="px-4 py-3 font-medium">{{ $vm->name }}</td>
@if(auth()->user()->isAdmin())
<td class="px-4 py-3 text-slate-400">{{ $vm->owner?->name ?? '—' }}</td>
@endif
<td class="px-4 py-3 font-mono text-xs">{{ $vm->vmid ?? '—' }}</td>
<td class="px-4 py-3 font-mono text-xs">
{{ $vm->ip_address ?? '—' }}
@if($vm->public_ip)<br><span class="text-violet-400">{{ $vm->public_ip }}</span>@endif
</td>
<td class="px-4 py-3 text-slate-400">{{ $vm->cpu }} vCPU · {{ $vm->ram }} MB · {{ $vm->disk }} GB</td>
<td class="px-4 py-3">
@include('partials.status-badge', ['status' => $vm->status])
@if($vm->proxmox_status)
<span class="ml-1 text-xs text-slate-500">({{ $vm->proxmox_status }})</span>
@endif
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('vms.show', $vm) }}" class="text-cyan-400 hover:underline">Details</a>
</td>
</tr>
@empty
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">Keine VMs gefunden.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">{{ $vms->links() }}</motionless>
@endsection

View File

@@ -0,0 +1,250 @@
@extends('layouts.app')
@section('title', $vm->name)
@section('heading', $vm->displayName())
@section('content')
@php
$canManage = auth()->user()->can('manage', $vm);
$isRunning = ($liveStatus['status'] ?? $vm->proxmox_status) === 'running';
@endphp
<div class="mb-6 flex flex-wrap gap-3">
@can('update', $vm)
<a href="{{ route('vms.edit', $vm) }}" class="rounded-lg border border-slate-700 px-4 py-2 text-sm hover:bg-slate-800">Konfiguration</a>
@endcan
@if($canManage)
<a href="{{ route('vms.console', $vm) }}" target="_blank" class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium hover:bg-violet-500 {{ $isRunning ? '' : 'pointer-events-none opacity-50' }}">Konsole öffnen</a>
@endif
@can('delete', $vm)
<form method="POST" action="{{ route('vms.destroy', $vm) }}" onsubmit="return confirm('VM wirklich löschen?')">
@csrf @method('DELETE')
<button class="rounded-lg border border-red-800 px-4 py-2 text-sm text-red-400 hover:bg-red-950">Löschen</button>
</form>
@endcan
</div>
@if($errors->has('power'))
<div class="mb-4 rounded-lg border border-red-800/50 bg-red-950/50 px-4 py-3 text-sm text-red-300">{{ $errors->first('power') }}</div>
@endif
@if($errors->has('iso'))
<div class="mb-4 rounded-lg border border-red-800/50 bg-red-950/50 px-4 py-3 text-sm text-red-300">{{ $errors->first('iso') }}</div>
@endif
@if($canManage)
<section class="mb-6 rounded-xl border border-slate-800 bg-slate-900/60 p-6" id="vm-management-panel">
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<h2 class="text-lg font-semibold">VM-Steuerung</h2>
<div class="flex items-center gap-3 text-sm">
<span id="live-status-badge" class="inline-flex items-center gap-2 rounded-full border px-3 py-1 {{ $isRunning ? 'border-emerald-800 bg-emerald-950 text-emerald-400' : 'border-slate-700 bg-slate-800 text-slate-400' }}">
<span class="h-2 w-2 rounded-full {{ $isRunning ? 'bg-emerald-400 animate-pulse' : 'bg-slate-500' }}"></span>
<span id="live-status-text">{{ $liveStatus['status'] ?? $vm->proxmox_status ?? 'unbekannt' }}</span>
</span>
<span class="text-slate-500" id="live-uptime">
@if(($liveStatus['uptime'] ?? $vm->proxmox_uptime ?? 0) > 0)
Uptime: {{ gmdate('H:i:s', $liveStatus['uptime'] ?? $vm->proxmox_uptime) }}
@endif
</span>
</div>
</div>
<form method="POST" action="{{ route('vms.power', $vm) }}" class="flex flex-wrap gap-2">
@csrf
@foreach(\App\Enums\VmPowerAction::cases() as $action)
<button type="submit" name="action" value="{{ $action->value }}"
class="rounded-lg border px-4 py-2 text-sm font-medium transition
{{ $action === \App\Enums\VmPowerAction::Stop || $action === \App\Enums\VmPowerAction::Reset ? 'border-red-800 text-red-400 hover:bg-red-950' : 'border-slate-700 hover:bg-slate-800' }}"
onclick="return confirm('{{ $action->label() }} ausführen?')">
{{ $action->label() }}
</button>
@endforeach
</form>
<div class="mt-6 grid gap-4 border-t border-slate-800 pt-6 lg:grid-cols-2">
<div>
<h3 class="mb-3 font-medium text-slate-300">Installations-ISO</h3>
@if($vm->attached_iso)
<p class="mb-2 font-mono text-sm text-cyan-400">{{ $vm->attached_iso }}</p>
<form method="POST" action="{{ route('vms.iso.unmount', $vm) }}" class="inline">
@csrf @method('DELETE')
<button class="text-sm text-red-400 hover:underline">ISO entfernen</button>
</form>
@else
<p class="mb-2 text-sm text-slate-500">Keine ISO eingebunden.</p>
@endif
<form method="POST" action="{{ route('vms.iso.mount', $vm) }}" class="mt-3 flex flex-wrap gap-2">
@csrf
<select name="iso_volid" required class="min-w-0 flex-1 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-sm">
<option value="">ISO wählen…</option>
@foreach($isos as $iso)
<option value="{{ $iso['volid'] }}">{{ $iso['label'] }} ({{ number_format($iso['size'] / 1024 / 1024 / 1024, 2) }} GB)</option>
@endforeach
</select>
<button type="submit" class="rounded-lg bg-cyan-600 px-4 py-2 text-sm hover:bg-cyan-500">Einbinden</button>
</form>
<p class="mt-2 text-xs text-slate-500">Nach dem Einbinden: VM neu starten und von CD booten.</p>
</div>
<div>
<h3 class="mb-3 font-medium text-slate-300">Live-Ressourcen</h3>
<dl class="space-y-2 text-sm" id="live-resources">
@if($liveStatus)
<div class="flex justify-between"><dt class="text-slate-400">CPU</dt><dd>{{ number_format(($liveStatus['cpu'] ?? 0) * 100, 1) }}%</dd></div>
<div class="flex justify-between"><dt class="text-slate-400">RAM</dt><dd>{{ number_format(($liveStatus['mem'] ?? 0) / 1024 / 1024 / 1024, 2) }} / {{ number_format(($liveStatus['maxmem'] ?? 0) / 1024 / 1024 / 1024, 2) }} GB</dd></div>
@else
<p class="text-slate-500">Status nicht abrufbar (Proxmox-Verbindung prüfen).</p>
@endif
</dl>
</div>
</div>
</section>
@endif
<div class="grid gap-6 lg:grid-cols-2">
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<h2 class="mb-4 font-semibold">Status</h2>
<dl class="space-y-3 text-sm">
<div class="flex justify-between"><dt class="text-slate-400">Provisioning</dt><dd>@include('partials.status-badge', ['status' => $vm->status])</dd></div>
<div class="flex justify-between"><dt class="text-slate-400">Schritt</dt><dd>{{ $vm->provisioning_step ?? '—' }}</dd></div>
@if($vm->error_message)
<div class="rounded bg-red-950/50 p-3 text-red-300">{{ $vm->error_message }}</div>
@endif
<div class="flex justify-between"><dt class="text-slate-400">VMID</dt><dd class="font-mono">{{ $vm->vmid ?? '—' }}</dd></div>
@if(auth()->user()->isAdmin())
<div class="flex justify-between"><dt class="text-slate-400">Besitzer</dt><dd>{{ $vm->owner?->name ?? '—' }}</dd></div>
@endif
</dl>
</section>
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<h2 class="mb-4 font-semibold">Netzwerk</h2>
<dl class="space-y-3 text-sm">
<div class="flex justify-between"><dt class="text-slate-400">Private IP</dt><dd class="font-mono">{{ $vm->ip_address ?? '—' }}</dd></div>
<div class="flex justify-between"><dt class="text-slate-400">Öffentliche IP</dt><dd class="font-mono text-violet-400">{{ $vm->public_ip ?? '—' }}</dd></div>
<div class="flex justify-between"><dt class="text-slate-400">Traefik</dt><dd>{{ $vm->behind_traefik ? 'Ja' : 'Nein' }}</dd></div>
<div class="flex justify-between"><dt class="text-slate-400">Domain</dt><dd>{{ str_ends_with($vm->domain ?? '', '.internal') ? '—' : $vm->domain }}</dd></div>
</dl>
</section>
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<h2 class="mb-4 font-semibold">Ressourcen (Konfiguration)</h2>
<dl class="space-y-3 text-sm">
<div class="flex justify-between"><dt class="text-slate-400">vCPUs</dt><dd>{{ $vm->cpu }}</dd></div>
<div class="flex justify-between"><dt class="text-slate-400">RAM</dt><dd>{{ $vm->ram }} MB</dd></div>
<div class="flex justify-between"><dt class="text-slate-400">Disk</dt><dd>{{ $vm->disk }} GB</dd></div>
</dl>
</section>
<section class="rounded-xl border border-slate-800 bg-slate-900/60 p-6">
<h2 class="mb-4 font-semibold">Aktivitätsprotokoll</h2>
<div class="max-h-48 space-y-2 overflow-y-auto text-xs">
@forelse($vm->activityLogs as $log)
<div class="flex justify-between gap-2 border-b border-slate-800 pb-2">
<span class="{{ $log->status === 'success' ? 'text-slate-300' : 'text-red-400' }}">{{ $log->action }}</span>
<span class="shrink-0 text-slate-500">{{ $log->created_at->diffForHumans() }}</span>
</div>
@empty
<p class="text-slate-500">Noch keine Aktionen.</p>
@endforelse
</div>
</section>
</div>
@if($canManage && $vm->vmid)
<section class="hexahost-card mb-6 p-6">
<h2 class="mb-4 text-lg font-semibold">Snapshots (48h)</h2>
<form method="POST" action="{{ route('vms.snapshots.store', $vm) }}" class="mb-4 flex gap-2">
@csrf
<input type="text" name="label" placeholder="Optional: Bezeichnung" class="hexahost-input flex-1 px-3 py-2 text-sm">
<button class="hexahost-btn-primary px-4 py-2 text-sm">Snapshot erstellen</button>
</form>
<ul class="space-y-2 text-sm">
@forelse($vm->snapshots as $snap)
<li class="flex flex-wrap items-center justify-between gap-2 border-b border-white/10 pb-2">
<span>{{ $snap->name }} @if($snap->auto_created)<span class="text-xs opacity-60">(auto)</span>@endif</span>
<span class="text-xs opacity-60">bis {{ $snap->expires_at?->format('d.m. H:i') }}</span>
<span class="flex gap-2">
<form method="POST" action="{{ route('vms.snapshots.rollback', [$vm, $snap]) }}">@csrf<button class="text-cyan-400 text-xs hover:underline">Rollback</button></form>
<form method="POST" action="{{ route('vms.snapshots.destroy', [$vm, $snap]) }}">@csrf @method('DELETE')<button class="text-red-400 text-xs hover:underline">Löschen</button></form>
</span>
</li>
@empty
<li class="opacity-60">Keine Snapshots.</li>
@endforelse
</ul>
</section>
<section class="hexahost-card mb-6 p-6">
<h2 class="mb-4 text-lg font-semibold">Backups @if(!config('hosting.backups.enabled'))<span class="text-xs opacity-60">(PBS deaktiviert)</span>@endif</h2>
@if(config('hosting.backups.enabled'))
<form method="POST" action="{{ route('vms.backups.store', $vm) }}">@csrf<button class="hexahost-btn-primary px-4 py-2 text-sm">Backup starten</button></form>
@endif
<ul class="mt-4 space-y-2 text-sm">
@forelse($vm->backups as $backup)
<li>{{ $backup->status }} {{ $backup->created_at->format('d.m.Y H:i') }}</li>
@empty
<li class="opacity-60">Keine Backups.</li>
@endforelse
</ul>
</section>
<section class="hexahost-card mb-6 p-6">
<h2 class="mb-4 text-lg font-semibold">Firewall</h2>
<form method="POST" action="{{ route('vms.firewall.store', $vm) }}" class="mb-4 grid gap-2 sm:grid-cols-5">
@csrf
<select name="direction" class="hexahost-input px-2 py-2 text-sm"><option value="in">Eingehend</option><option value="out">Ausgehend</option></select>
<select name="action" class="hexahost-input px-2 py-2 text-sm"><option value="ACCEPT">ACCEPT</option><option value="DROP">DROP</option></select>
<select name="protocol" class="hexahost-input px-2 py-2 text-sm"><option value="tcp">TCP</option><option value="udp">UDP</option></select>
<input name="port" placeholder="Port" class="hexahost-input px-2 py-2 text-sm">
<button class="hexahost-btn-primary px-3 py-2 text-sm">Hinzufügen</button>
</form>
<ul class="space-y-1 text-sm">
@forelse($vm->firewallRules as $rule)
<li class="flex justify-between"><span>{{ $rule->direction }} {{ $rule->protocol }} {{ $rule->port }} {{ $rule->action }}</span>
<form method="POST" action="{{ route('vms.firewall.destroy', [$vm, $rule->id]) }}">@csrf @method('DELETE')<button class="text-red-400 text-xs">×</button></form></li>
@empty
<li class="opacity-60">Keine Regeln.</li>
@endforelse
</ul>
</section>
<section class="hexahost-card mb-6 p-6">
<h2 class="mb-4 text-lg font-semibold">Neuinstallation</h2>
<form method="POST" action="{{ route('vms.reinstall', $vm) }}" onsubmit="return confirm('VM wird gelöscht und neu provisioniert. Fortfahren?')">
@csrf
<input type="hidden" name="confirm" value="REINSTALL">
<button class="rounded-lg border border-red-500/50 px-4 py-2 text-sm text-red-400 hover:bg-red-950/30">Neuinstallation starten</button>
</form>
</section>
@endif
@if($canManage && $vm->vmid)
@push('scripts')
<script>
(function () {
const statusUrl = @json(route('vms.status', $vm));
const badge = document.getElementById('live-status-badge');
const text = document.getElementById('live-status-text');
const uptime = document.getElementById('live-uptime');
async function refresh() {
try {
const res = await fetch(statusUrl, { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
const data = await res.json();
if (!data.proxmox) return;
const running = data.proxmox.status === 'running';
text.textContent = data.proxmox.status;
badge.className = 'inline-flex items-center gap-2 rounded-full border px-3 py-1 ' +
(running ? 'border-emerald-800 bg-emerald-950 text-emerald-400' : 'border-slate-700 bg-slate-800 text-slate-400');
if (data.proxmox.uptime > 0) {
const h = Math.floor(data.proxmox.uptime / 3600);
const m = Math.floor((data.proxmox.uptime % 3600) / 60);
const s = data.proxmox.uptime % 60;
uptime.textContent = 'Uptime: ' + [h,m,s].map(v => String(v).padStart(2,'0')).join(':');
}
} catch (e) { /* ignore */ }
}
setInterval(refresh, 10000);
})();
</script>
@endpush
@endif
@endsection

File diff suppressed because one or more lines are too long