initial commit
This commit is contained in:
15
resources/views/admin/health.blade.php
Normal file
15
resources/views/admin/health.blade.php
Normal 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
|
||||
15
resources/views/admin/templates/create.blade.php
Normal file
15
resources/views/admin/templates/create.blade.php
Normal 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
|
||||
15
resources/views/admin/templates/edit.blade.php
Normal file
15
resources/views/admin/templates/edit.blade.php
Normal 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
|
||||
25
resources/views/admin/templates/index.blade.php
Normal file
25
resources/views/admin/templates/index.blade.php
Normal 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
|
||||
40
resources/views/auth/login.blade.php
Normal file
40
resources/views/auth/login.blade.php
Normal 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>
|
||||
14
resources/views/auth/two-factor-challenge.blade.php
Normal file
14
resources/views/auth/two-factor-challenge.blade.php
Normal 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
|
||||
16
resources/views/auth/two-factor-setup.blade.php
Normal file
16
resources/views/auth/two-factor-setup.blade.php
Normal 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
|
||||
69
resources/views/dashboard.blade.php
Normal file
69
resources/views/dashboard.blade.php
Normal 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
|
||||
31
resources/views/ip-pools/create.blade.php
Normal file
31
resources/views/ip-pools/create.blade.php
Normal 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
|
||||
35
resources/views/ip-pools/edit.blade.php
Normal file
35
resources/views/ip-pools/edit.blade.php
Normal 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
|
||||
64
resources/views/ip-pools/index.blade.php
Normal file
64
resources/views/ip-pools/index.blade.php
Normal 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
|
||||
24
resources/views/iso-uploads/index.blade.php
Normal file
24
resources/views/iso-uploads/index.blade.php
Normal 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
|
||||
64
resources/views/layouts/app.blade.php
Normal file
64
resources/views/layouts/app.blade.php
Normal 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>
|
||||
12
resources/views/partials/status-badge.blade.php
Normal file
12
resources/views/partials/status-badge.blade.php
Normal 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>
|
||||
28
resources/views/partials/vm-device-fields.blade.php
Normal file
28
resources/views/partials/vm-device-fields.blade.php
Normal 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
|
||||
25
resources/views/partials/vm-device-row.blade.php
Normal file
25
resources/views/partials/vm-device-row.blade.php
Normal 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>
|
||||
25
resources/views/users/create.blade.php
Normal file
25
resources/views/users/create.blade.php
Normal 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
|
||||
33
resources/views/users/edit.blade.php
Normal file
33
resources/views/users/edit.blade.php
Normal 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
|
||||
37
resources/views/users/index.blade.php
Normal file
37
resources/views/users/index.blade.php
Normal 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
|
||||
15
resources/views/vms/console-error.blade.php
Normal file
15
resources/views/vms/console-error.blade.php
Normal 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>
|
||||
55
resources/views/vms/console.blade.php
Normal file
55
resources/views/vms/console.blade.php
Normal 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>
|
||||
98
resources/views/vms/create.blade.php
Normal file
98
resources/views/vms/create.blade.php
Normal 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
|
||||
33
resources/views/vms/edit.blade.php
Normal file
33
resources/views/vms/edit.blade.php
Normal 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
|
||||
61
resources/views/vms/index.blade.php
Normal file
61
resources/views/vms/index.blade.php
Normal 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
|
||||
250
resources/views/vms/show.blade.php
Normal file
250
resources/views/vms/show.blade.php
Normal 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
|
||||
223
resources/views/welcome.blade.php
Normal file
223
resources/views/welcome.blade.php
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user