251 lines
14 KiB
PHP
251 lines
14 KiB
PHP
@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
|