723 lines
49 KiB
PHP
723 lines
49 KiB
PHP
@php
|
||
// Threshold tone for gauges/bars: >=90 offline, >=75 warning, else online.
|
||
$tone = fn (int $v): string => $v >= 90 ? 'offline' : ($v >= 75 ? 'warning' : 'online');
|
||
$barColor = ['online' => 'bg-online', 'warning' => 'bg-warning', 'offline' => 'bg-offline'];
|
||
$statusLabel = ['online' => __('servers.status_online'), 'warning' => __('servers.status_warning'), 'offline' => __('servers.status_offline'), 'pending' => __('servers.status_pending')][$server->status] ?? ucfirst($server->status);
|
||
|
||
$specs = $server->specs ?? [];
|
||
$cores = $specs['cores'] ?? null;
|
||
$ramGb = $specs['ram_gb'] ?? null;
|
||
$diskGb = $specs['disk_gb'] ?? null;
|
||
$ramUsed = $ramGb !== null ? round((float) $ramGb * $server->mem / 100, 1) : null;
|
||
$diskUsed = $diskGb !== null ? (int) round((float) $diskGb * $server->disk / 100) : null;
|
||
|
||
$specRows = [
|
||
[__('servers.spec_arch'), $specs['arch'] ?? '—'],
|
||
[__('servers.spec_kernel'), $specs['kernel'] ?? '—'],
|
||
[__('servers.spec_virt'), $specs['virt'] ?? '—'],
|
||
[__('servers.spec_cores'), $cores ?? '—'],
|
||
[__('servers.spec_ram'), $ramGb !== null ? __('servers.gb', ['value' => $ramGb]) : '—'],
|
||
[__('servers.spec_disk'), $diskGb !== null ? __('servers.gb', ['value' => $diskGb]) : '—'],
|
||
];
|
||
|
||
// Detected OS tooling (populated once the live profile loads).
|
||
if (! empty($os)) {
|
||
$pm = ($os['packageManager'] ?? 'none') !== 'none' ? $os['packageManager'] : '—';
|
||
$fw = ($os['firewallTool'] ?? 'none') !== 'none' ? $os['firewallTool'] : '—';
|
||
$specRows[] = [__('servers.spec_system'), $os['familyLabel'] ?? '—'];
|
||
$specRows[] = [__('servers.spec_package_manager'), $pm];
|
||
$specRows[] = [__('servers.spec_firewall'), $fw];
|
||
}
|
||
|
||
$vitals = [
|
||
['label' => __('servers.vital_cpu'), 'icon' => 'cpu', 'val' => (int) $server->cpu, 'sub' => ($loadAvg > 0 ? __('servers.load', ['value' => number_format($loadAvg, 2)]).' · ' : '').($cores ? __('servers.cores_count', ['count' => $cores]) : '—')],
|
||
['label' => __('servers.vital_ram'), 'icon' => 'activity', 'val' => (int) $server->mem, 'sub' => $ramUsed !== null ? __('servers.ram_used_of_total', ['used' => $ramUsed, 'total' => $ramGb]) : '—'],
|
||
['label' => __('servers.vital_disk'), 'icon' => 'server', 'val' => (int) $server->disk, 'sub' => $diskUsed !== null ? __('servers.disk_used_of_total', ['used' => $diskUsed, 'total' => $diskGb]) : '—'],
|
||
];
|
||
@endphp
|
||
|
||
<div class="space-y-5" wire:poll.10s="pollMetrics" wire:init="load">
|
||
<a href="{{ route('servers.index') }}"
|
||
class="inline-flex min-h-11 items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.2em] text-ink-3 hover:text-ink-2">
|
||
<x-icon name="switcher" class="h-3.5 w-3.5 rotate-90" />
|
||
{{ __('servers.back_to_fleet') }}
|
||
</a>
|
||
|
||
{{-- Hero header --}}
|
||
<div class="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-line bg-surface p-5 shadow-panel">
|
||
<div class="flex min-w-0 items-center gap-4">
|
||
<span @class([
|
||
'grid h-12 w-12 shrink-0 place-items-center rounded-lg border',
|
||
'border-online/30 bg-online/10 text-online' => $server->status === 'online',
|
||
'border-warning/30 bg-warning/10 text-warning' => $server->status === 'warning',
|
||
'border-offline/30 bg-offline/10 text-offline' => $server->status === 'offline',
|
||
'border-cyan/30 bg-cyan/10 text-cyan' => $server->status === 'pending',
|
||
])>
|
||
<x-icon name="server" class="h-6 w-6" />
|
||
</span>
|
||
<div class="min-w-0">
|
||
<div class="flex flex-wrap items-center gap-2.5">
|
||
<h2 class="truncate font-display text-2xl font-semibold text-ink">{{ $server->name }}</h2>
|
||
<x-status-pill :status="$server->status">{{ $statusLabel }}</x-status-pill>
|
||
</div>
|
||
<p class="mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-1 font-mono text-[11px] text-ink-3">
|
||
<span class="text-ink-2">{{ $server->hostname }}</span>
|
||
<span class="text-ink-4">·</span>
|
||
<span>{{ $server->ip }}</span>
|
||
<span class="text-ink-4">·</span>
|
||
<span>{{ $server->os }}</span>
|
||
<span class="text-ink-4">·</span>
|
||
<span>{{ __('servers.uptime', ['value' => $server->uptime ?: '—']) }}</span>
|
||
<span class="text-ink-4">·</span>
|
||
<span>{{ __('servers.last_seen', ['value' => optional($server->last_seen_at)->diffForHumans() ?? '—']) }}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex shrink-0 items-center gap-2">
|
||
@can('manage-fleet')
|
||
<x-modal-trigger variant="secondary" component="modals.system-update" :arguments="['serverId' => $server->id]">
|
||
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('servers.system_updates') }}
|
||
</x-modal-trigger>
|
||
@endcan
|
||
<x-btn variant="secondary" wire:click="openFiles">
|
||
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('servers.files') }}
|
||
</x-btn>
|
||
</div>
|
||
</div>
|
||
|
||
<x-server-tabs :tab="$tab" />
|
||
|
||
@if ($tab === 'docker')
|
||
<livewire:servers.server-docker :server="$server" :key="'sd-'.$server->id" />
|
||
@elseif ($tab === 'terminal')
|
||
<livewire:servers.server-terminal :server="$server" :key="'st-'.$server->id" />
|
||
@else
|
||
|
||
@if ($ready && ! $connected)
|
||
<div class="flex items-center gap-2.5 rounded-lg border border-warning/25 bg-warning/10 px-4 py-3">
|
||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||
<p class="text-sm text-ink-2">{{ __('servers.offline_banner') }}</p>
|
||
</div>
|
||
@endif
|
||
|
||
{{-- SSH-Zugang (Credential) --}}
|
||
@php
|
||
$cred = $server->credential;
|
||
$credStatus = $cred ? ($cred->disabled ? 'offline' : 'online') : null;
|
||
$credStatusLabel = $cred ? ($cred->disabled ? __('servers.cred_locked') : __('servers.cred_active')) : null;
|
||
$credAuthLabel = $cred ? ($cred->auth_type === 'key' ? __('servers.cred_auth_key') : __('servers.cred_auth_password')) : null;
|
||
@endphp
|
||
<x-panel :title="__('servers.ssh_access_title')" :subtitle="__('servers.ssh_access_subtitle')" :padded="false">
|
||
@if ($cred)
|
||
<div class="flex flex-wrap items-center gap-x-4 gap-y-3 px-4 py-4 sm:px-5">
|
||
<span @class([
|
||
'grid h-10 w-10 shrink-0 place-items-center rounded-md border',
|
||
'border-online/30 bg-online/10 text-online' => ! $cred->disabled,
|
||
'border-offline/30 bg-offline/10 text-offline' => $cred->disabled,
|
||
])>
|
||
<x-icon name="shield" class="h-5 w-5" />
|
||
</span>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<p class="truncate font-display text-sm font-semibold text-ink">{{ $cred->name ?: $cred->username }}</p>
|
||
<x-status-pill :status="$credStatus" class="shrink-0">{{ $credStatusLabel }}</x-status-pill>
|
||
<x-badge tone="neutral" size="md" class="shrink-0">{{ $credAuthLabel }}</x-badge>
|
||
</div>
|
||
<p class="mt-1.5 truncate font-mono text-[11px] text-ink-3">
|
||
<span class="text-ink-2">{{ $cred->username }}</span>@<span>{{ $server->ip }}</span>
|
||
</p>
|
||
</div>
|
||
@can('manage-fleet')
|
||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||
<x-modal-trigger variant="secondary" component="modals.edit-credential" :arguments="['serverId' => $server->id]">
|
||
<x-icon name="settings" class="h-3.5 w-3.5" /> {{ __('common.edit') }}
|
||
</x-modal-trigger>
|
||
<x-btn variant="secondary" wire:click="toggleCredential" wire:target="toggleCredential" wire:loading.attr="disabled">
|
||
<x-icon name="power" class="h-3.5 w-3.5" wire:loading.remove wire:target="toggleCredential" />
|
||
<svg wire:loading wire:target="toggleCredential" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||
{{ $cred->disabled ? __('common.unlock') : __('common.lock') }}
|
||
</x-btn>
|
||
<x-modal-trigger variant="danger-soft" action="confirmDeleteCredential">
|
||
<x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('common.delete') }}
|
||
</x-modal-trigger>
|
||
</div>
|
||
@endcan
|
||
</div>
|
||
@else
|
||
<div class="flex flex-wrap items-center gap-x-4 gap-y-3 px-4 py-4 sm:px-5">
|
||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-line bg-inset text-ink-3">
|
||
<x-icon name="shield" class="h-5 w-5" />
|
||
</span>
|
||
<div class="min-w-0 flex-1">
|
||
<p class="truncate font-display text-sm font-semibold text-ink">{{ __('servers.cred_none_title') }}</p>
|
||
<p class="mt-1.5 font-mono text-[11px] text-ink-3">{{ __('servers.cred_none_hint') }}</p>
|
||
</div>
|
||
@can('manage-fleet')
|
||
<x-modal-trigger variant="accent" class="shrink-0" component="modals.edit-credential" :arguments="['serverId' => $server->id]">
|
||
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.cred_deposit') }}
|
||
</x-modal-trigger>
|
||
@endcan
|
||
</div>
|
||
@endif
|
||
</x-panel>
|
||
|
||
{{-- Vitals: live gauges, full width, dense --}}
|
||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||
@foreach ($vitals as $v)
|
||
<div class="flex items-center gap-4 rounded-xl border border-line bg-surface p-4 shadow-panel">
|
||
<x-ring :value="$v['val']" size="lg" :tone="$tone($v['val'])" />
|
||
<div class="min-w-0">
|
||
<p class="flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.2em] text-accent-text">
|
||
<x-icon :name="$v['icon']" class="h-3.5 w-3.5" />{{ $v['label'] }}
|
||
</p>
|
||
<p class="mt-2 font-mono text-[11px] leading-relaxed text-ink-4">{{ $v['sub'] }}</p>
|
||
</div>
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
|
||
{{-- Metric history graph — self-contained Alpine island (fetches /servers/<uuid>/history.json) --}}
|
||
<div wire:ignore x-data="metricChart('{{ $server->uuid }}')">
|
||
<x-panel :title="__('servers.history_title')" :subtitle="__('servers.history_subtitle')">
|
||
<x-slot:actions>
|
||
<span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-accent"></span><span class="text-ink-2">CPU</span></span>
|
||
<span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-cyan"></span><span class="text-ink-2">MEM</span></span>
|
||
<span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-online"></span><span class="text-ink-2">DISK</span></span>
|
||
</x-slot:actions>
|
||
|
||
{{-- range selector (client-side, instant) --}}
|
||
<div class="mb-3 flex gap-1.5">
|
||
<template x-for="r in ['1h','24h','7d','30d']" :key="r">
|
||
<button type="button" x-on:click="setRange(r)"
|
||
class="min-h-8 rounded-md border px-3 py-1 font-mono text-[11px] transition-colors"
|
||
:class="range === r ? 'border-accent/40 bg-accent/10 text-accent-text' : 'border-line text-ink-3 hover:border-line-strong hover:text-ink'"
|
||
x-text="({'1h':'1 h','24h':'24 h','7d':'7 d','30d':'30 d'})[r]"></button>
|
||
</template>
|
||
</div>
|
||
|
||
{{-- empty / loading --}}
|
||
<p x-show="!hasData && !loading" class="py-10 text-center font-mono text-[11px] text-ink-4">{{ __('servers.history_empty') }}</p>
|
||
<p x-show="!hasData && loading" class="py-10 text-center font-mono text-[11px] text-ink-4">{{ __('servers.history_subtitle') }} …</p>
|
||
|
||
{{-- chart --}}
|
||
<div x-show="hasData" x-cloak>
|
||
<div class="relative pl-7">
|
||
<div class="pointer-events-none absolute inset-y-0 left-0 w-6 font-mono text-[9px] text-ink-4">
|
||
<span class="absolute right-0 top-0 -translate-y-1/2">100</span>
|
||
<span class="absolute right-0 top-1/4 -translate-y-1/2">75</span>
|
||
<span class="absolute right-0 top-1/2 -translate-y-1/2">50</span>
|
||
<span class="absolute right-0 top-3/4 -translate-y-1/2">25</span>
|
||
<span class="absolute right-0 top-full -translate-y-1/2">0</span>
|
||
</div>
|
||
|
||
{{-- hover tooltip (positioned at the crosshair) --}}
|
||
<div x-show="hover" x-cloak class="pointer-events-none absolute top-1 z-10 -translate-x-1/2 rounded-md border border-line bg-raised/95 px-2.5 py-1.5 shadow-pop backdrop-blur"
|
||
:style="hover ? ('left:' + Math.max(8, Math.min(92, hover.left)) + '%') : ''">
|
||
<p class="font-mono text-[10px] text-ink-4" x-text="hover ? fmtTime(hover.p.t) : ''"></p>
|
||
<p class="font-mono text-[11px] text-accent-text"><span x-text="hover ? hover.p.cpu : ''"></span>% CPU</p>
|
||
<p class="font-mono text-[11px] text-cyan-bright"><span x-text="hover ? hover.p.mem : ''"></span>% MEM</p>
|
||
<p class="font-mono text-[11px] text-online"><span x-text="hover ? hover.p.disk : ''"></span>% DISK</p>
|
||
</div>
|
||
|
||
<div class="h-56 w-full">
|
||
<svg viewBox="0 0 1000 260" preserveAspectRatio="none" class="h-full w-full"
|
||
x-on:mousemove="onMove($event)" x-on:mouseleave="onLeave()">
|
||
<defs>
|
||
<linearGradient id="mh-area" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="currentColor" stop-opacity="0.22" />
|
||
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
|
||
</linearGradient>
|
||
</defs>
|
||
<g class="text-line-soft">
|
||
@foreach ([0, 25, 50, 75, 100] as $g)
|
||
<line x1="0" x2="1000" y1="{{ 260 * (1 - $g / 100) }}" y2="{{ 260 * (1 - $g / 100) }}" stroke="currentColor" stroke-width="1" vector-effect="non-scaling-stroke" />
|
||
@endforeach
|
||
</g>
|
||
<g class="text-online">
|
||
<path :d="area('disk')" fill="url(#mh-area)" stroke="none" />
|
||
<path :d="line('disk')" fill="none" stroke="currentColor" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" />
|
||
</g>
|
||
<g class="text-cyan">
|
||
<path :d="area('mem')" fill="url(#mh-area)" stroke="none" />
|
||
<path :d="line('mem')" fill="none" stroke="currentColor" stroke-width="2.25" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" />
|
||
</g>
|
||
<g class="text-accent">
|
||
<path :d="area('cpu')" fill="url(#mh-area)" stroke="none" />
|
||
<path :d="line('cpu')" fill="none" stroke="currentColor" stroke-width="2.5" vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round" />
|
||
</g>
|
||
{{-- crosshair + per-series dots (guarded bindings; no <template> inside SVG) --}}
|
||
<g x-show="hover" x-cloak>
|
||
<line class="text-line-strong" :x1="(hover ? hover.left : 0) * 10" :x2="(hover ? hover.left : 0) * 10" y1="0" y2="260" stroke="currentColor" stroke-width="1" stroke-dasharray="4 4" vector-effect="non-scaling-stroke" />
|
||
<circle class="text-online" :cx="(hover ? hover.left : 0) * 10" :cy="hover ? _y(hover.p.disk) : 0" r="4" fill="currentColor" />
|
||
<circle class="text-cyan" :cx="(hover ? hover.left : 0) * 10" :cy="hover ? _y(hover.p.mem) : 0" r="4" fill="currentColor" />
|
||
<circle class="text-accent" :cx="(hover ? hover.left : 0) * 10" :cy="hover ? _y(hover.p.cpu) : 0" r="4" fill="currentColor" />
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
</div>{{-- /relative chart box --}}
|
||
|
||
<div class="mt-3 flex justify-between pl-7 font-mono text-[9px] text-ink-4">
|
||
<span x-text="'−' + ({'1h':'1 h','24h':'24 h','7d':'7 d','30d':'30 d'})[range]"></span>
|
||
<span>{{ __('dashboard.axis_now') }}</span>
|
||
</div>
|
||
</div>
|
||
</x-panel>
|
||
</div>
|
||
|
||
@if ($ready)
|
||
{{-- Spezifikationen + Sicherheit --}}
|
||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||
<x-panel :title="__('servers.specs_title')" :subtitle="__('servers.specs_subtitle')" :padded="false">
|
||
<dl class="divide-y divide-line">
|
||
@foreach ($specRows as [$key, $val])
|
||
<div class="flex items-center justify-between gap-3 px-4 py-2.5 sm:px-5">
|
||
<dt class="text-sm text-ink-2">{{ $key }}</dt>
|
||
<dd class="truncate font-mono text-sm text-ink">{{ $val }}</dd>
|
||
</div>
|
||
@endforeach
|
||
</dl>
|
||
</x-panel>
|
||
|
||
<x-panel :title="__('servers.security_title')" :subtitle="__('servers.security_subtitle')" :padded="false">
|
||
{{-- Busy cue while the post-toggle security re-read runs (up to a few SSH round-trips). --}}
|
||
<x-slot:actions>
|
||
<svg wire:loading wire:target="reloadSnapshot" class="h-3.5 w-3.5 animate-spin text-ink-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||
</x-slot:actions>
|
||
<div class="divide-y divide-line">
|
||
@foreach ($hardening as $check)
|
||
@php
|
||
$supported = $check['supported'] ?? true;
|
||
// Neutral rows (auto-updates) are a preference, not a security
|
||
// verdict: muted glyph + aktiv/inaktiv, never SICHER/OFFEN.
|
||
$neutral = $check['neutral'] ?? false;
|
||
@endphp
|
||
{{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen);
|
||
unsupported/neutral items render muted, no warning tone. --}}
|
||
<div wire:key="hard-{{ $check['key'] }}" class="flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-raised/40 sm:px-5">
|
||
<span @class([
|
||
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
|
||
'border-online/25 bg-online/10 text-online' => $supported && ! $neutral && $check['secure'],
|
||
'border-warning/25 bg-warning/10 text-warning' => $supported && ! $neutral && ! $check['secure'],
|
||
'border-line bg-raised text-ink-4' => ! $supported || $neutral,
|
||
])>
|
||
<x-icon :name="$supported && ! $neutral && ! $check['secure'] ? 'lock-open' : 'lock'" class="h-4 w-4" />
|
||
</span>
|
||
<div class="min-w-0 flex-1">
|
||
<p class="flex items-center gap-2 truncate text-sm text-ink">
|
||
{{ $check['label'] }}
|
||
@if (! $supported)
|
||
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.unsupported') }}</span>
|
||
@elseif ($neutral)
|
||
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-3">{{ $check['featureOn'] ? __('common.active') : __('common.inactive') }}</span>
|
||
@else
|
||
<span @class([
|
||
'shrink-0 font-mono text-[10px] uppercase tracking-wider',
|
||
'text-online' => $check['secure'],
|
||
'text-warning' => ! $check['secure'],
|
||
])>{{ $check['secure'] ? __('servers.check_secure') : __('servers.check_open') }}</span>
|
||
@endif
|
||
</p>
|
||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $supported ? $check['detail'] : $check['reason'] }}</p>
|
||
{{-- Key-only warning: disabling password login leaves SSH-key access only. --}}
|
||
@if ($supported && $check['key'] === 'ssh_password' && $check['featureOn'])
|
||
<p class="mt-0.5 font-mono text-[11px] text-ink-4">{{ __('servers.ssh_key_only_hint') }}</p>
|
||
@endif
|
||
</div>
|
||
@if ($supported)
|
||
@if ($check['key'] === 'fail2ban')
|
||
@can('manage-network')
|
||
<x-modal-trigger variant="secondary" size="sm" icon class="shrink-0" title="{{ __('servers.fail2ban_configure') }}"
|
||
component="modals.fail2ban-config" :arguments="['serverId' => $server->id]">
|
||
<x-icon name="settings" class="h-3.5 w-3.5" />
|
||
</x-modal-trigger>
|
||
@endcan
|
||
@endif
|
||
@if ($check['key'] === 'ssh_password' && $check['featureOn'])
|
||
{{-- Safe auto-flow: generate+install a key, verify, switch the panel credential, THEN disable. --}}
|
||
@can('manage-fleet')
|
||
<x-modal-trigger variant="secondary" size="sm" class="shrink-0"
|
||
component="modals.ssh-key-provision" :arguments="['serverId' => $server->id]">
|
||
{{ __('servers.ssh_key_provision_action') }}
|
||
</x-modal-trigger>
|
||
@endcan
|
||
@else
|
||
@can('manage-network')
|
||
{{-- Disabled while the post-apply re-read runs — prevents re-opening the
|
||
modal against the stale pre-toggle state ("2-3 Versuche"-Effekt). --}}
|
||
<x-modal-trigger variant="secondary" size="sm" class="shrink-0"
|
||
wire:loading.attr="disabled" wire:target="reloadSnapshot"
|
||
component="modals.hardening-action"
|
||
:arguments="['serverId' => $server->id, 'action' => $check['key'], 'enable' => ! $check['featureOn']]">
|
||
{{ $check['featureOn'] ? __('common.disable') : __('common.enable') }}
|
||
</x-modal-trigger>
|
||
@endcan
|
||
@endif
|
||
@endif
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
</x-panel>
|
||
</div>
|
||
|
||
{{-- Firewall + fail2ban: only the tools actually installed, side-by-side on lg --}}
|
||
@php
|
||
$fw = $firewall ?? [];
|
||
$fwTool = $fw['tool'] ?? 'ufw';
|
||
$fwToolLabel = $fwTool === 'firewalld' ? 'firewalld' : 'UFW';
|
||
$fwSupported = $fw['supported'] ?? false;
|
||
$fwInstalled = $fw['installed'] ?? false;
|
||
$fwActive = $fw['active'] ?? false;
|
||
$fwManageable = $fw['manageable'] ?? false;
|
||
$fwReadOnly = $fw['readOnly'] ?? false;
|
||
$fwReadError = $fw['readError'] ?? false;
|
||
$fwRules = $fw['rules'] ?? [];
|
||
$fwDefaults = $fw['defaults'] ?? [];
|
||
$actTone = ['ALLOW' => 'online', 'LIMIT' => 'warning', 'DENY' => 'offline', 'REJECT' => 'offline'];
|
||
|
||
$f2b = $fail2ban ?? [];
|
||
$f2bSupported = $f2b['supported'] ?? false;
|
||
$f2bReadError = $f2b['readError'] ?? false;
|
||
$f2bInstalled = $f2b['installed'] ?? false;
|
||
$f2bActive = $f2b['active'] ?? false;
|
||
$f2bJails = $f2b['jails'] ?? [];
|
||
$f2bIgnore = $f2b['ignoreip'] ?? [];
|
||
$f2bBanned = array_sum(array_map(fn ($j) => $j['currentlyBanned'] ?? 0, $f2bJails));
|
||
|
||
// Show a panel when its tool is actually installed (active or inactive), OR
|
||
// when the privileged read FAILED (readError) — a failed probe must stay
|
||
// visible so the operator can tell it apart from a genuinely-absent tool
|
||
// (both report installed=false). When truly absent, the Sicherheit checklist
|
||
// row carries the "Aktivieren" install path — so the redundant "nicht
|
||
// installiert" box is gone.
|
||
$showFw = $fwSupported && ($fwInstalled || $fwReadError);
|
||
$showF2b = $f2bSupported && ($f2bInstalled || $f2bReadError);
|
||
$panelCount = ($showFw ? 1 : 0) + ($showF2b ? 1 : 0);
|
||
|
||
// A single installed tool would otherwise sit in a capped, left-aligned box
|
||
// beside a large void. Instead the lone panel spans the full content width and
|
||
// splits its OWN body into two columns on lg — a rich, deliberate card rather
|
||
// than a fragment. Two tools keep the side-by-side grid.
|
||
$lone = $panelCount === 1;
|
||
@endphp
|
||
|
||
@if ($panelCount > 0)
|
||
{{-- Two tools → side-by-side; a lone tool spans full width and lays its body out
|
||
internally in two columns (see $lone), so neither case leaves dead space. --}}
|
||
<div @class([
|
||
'grid grid-cols-1 gap-3',
|
||
'lg:grid-cols-2 lg:items-start' => $panelCount === 2,
|
||
])>
|
||
@if ($showFw)
|
||
{{-- Firewall-Regeln --}}
|
||
<x-panel :title="__('servers.firewall_title')"
|
||
:subtitle="$fwToolLabel . ($fwActive ? __('servers.firewall_sub_active') : __('servers.firewall_sub_inactive'))"
|
||
:padded="false">
|
||
{{-- Adding rules to an INACTIVE firewall would suggest they take effect — gate on active. --}}
|
||
@if ($fwManageable && $fwActive)
|
||
@can('manage-network')
|
||
<x-slot:actions>
|
||
<x-modal-trigger variant="accent" size="sm"
|
||
component="modals.firewall-rule" :arguments="['serverId' => $server->id, 'tool' => $fwTool]">
|
||
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.firewall_add_rule') }}
|
||
</x-modal-trigger>
|
||
</x-slot:actions>
|
||
@endcan
|
||
@endif
|
||
|
||
@if ($fwReadError)
|
||
<div class="flex flex-wrap items-center gap-2.5 px-4 py-4 sm:px-5">
|
||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_read_error') }}</p>
|
||
</div>
|
||
@elseif (! $fwActive)
|
||
{{-- Inactive firewall (ufw OR firewalld): rules are not enforced, so showing the
|
||
rules table would mislead — compact hint + Aktivieren instead. --}}
|
||
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
|
||
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_inactive_hint', ['tool' => $fwToolLabel]) }}</p>
|
||
@can('manage-network')
|
||
<x-modal-trigger variant="secondary" size="sm"
|
||
wire:loading.attr="disabled" wire:target="reloadSnapshot"
|
||
component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'firewall', 'enable' => true]">
|
||
{{ __('common.enable') }}
|
||
</x-modal-trigger>
|
||
@endcan
|
||
</div>
|
||
@else
|
||
{{-- Lone firewall: defaults/state in the left column, rules list in the
|
||
right. Two-tool case keeps the single stacked column. --}}
|
||
<div @class(['lg:grid lg:grid-cols-2 lg:divide-x lg:divide-line' => $lone])>
|
||
<div>
|
||
<div class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2.5 sm:px-5">
|
||
@if ($fwTool === 'ufw')
|
||
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.firewall_default') }}</span>
|
||
<x-badge tone="neutral">{{ __('servers.firewall_incoming', ['value' => $fwDefaults['incoming'] ?? '—']) }}</x-badge>
|
||
<x-badge tone="neutral">{{ __('servers.firewall_outgoing', ['value' => $fwDefaults['outgoing'] ?? '—']) }}</x-badge>
|
||
@else
|
||
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.firewall_zone') }}</span>
|
||
<x-badge tone="neutral">{{ $fwDefaults['zone'] ?? '—' }}</x-badge>
|
||
@endif
|
||
</div>
|
||
|
||
@if ($fwReadOnly)
|
||
<div class="flex items-start gap-2.5 border-b border-line bg-inset px-4 py-2.5 sm:px-5">
|
||
<x-icon name="lock" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" />
|
||
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ __('servers.firewall_readonly') }}</p>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
|
||
<div class="divide-y divide-line">
|
||
@forelse ($fwRules as $rule)
|
||
{{-- $ruleTone, NOT $tone: the page-level $tone is a closure used by the
|
||
gauges/Volumes panel; reusing the name here would clobber it. --}}
|
||
@php $ruleTone = $actTone[$rule['action'] ?? 'ALLOW'] ?? 'online'; @endphp
|
||
<div wire:key="fw-{{ md5($rule['raw'] ?? ($rule['label'] ?? $loop->index)) }}" class="flex items-center gap-3 px-4 py-2.5 sm:px-5">
|
||
<span @class([
|
||
'h-2 w-2 shrink-0 rounded-full',
|
||
'bg-online' => $ruleTone === 'online',
|
||
'bg-warning' => $ruleTone === 'warning',
|
||
'bg-offline' => $ruleTone === 'offline',
|
||
])></span>
|
||
<div class="min-w-0 flex-1">
|
||
<p class="truncate font-mono text-sm text-ink">{{ $rule['label'] ?? ($rule['to'] ?? $rule['raw']) }}</p>
|
||
@if ($fwTool === 'ufw' && ($rule['from'] ?? '') !== '' && ! \Illuminate\Support\Str::contains($rule['from'], 'Anywhere'))
|
||
<p class="truncate font-mono text-[11px] text-ink-3">{{ __('servers.firewall_rule_from', ['value' => $rule['from']]) }}</p>
|
||
@endif
|
||
</div>
|
||
<span @class([
|
||
'shrink-0 font-mono text-[10px] uppercase tracking-wider',
|
||
'text-online' => $ruleTone === 'online',
|
||
'text-warning' => $ruleTone === 'warning',
|
||
'text-offline' => $ruleTone === 'offline',
|
||
])>{{ strtolower($rule['action'] ?? 'allow') }}</span>
|
||
@unless ($fwReadOnly)
|
||
@can('manage-network')
|
||
<x-modal-trigger variant="danger-soft" size="sm" icon class="shrink-0" title="{{ __('servers.firewall_remove_rule') }}"
|
||
action="confirmDeleteRule({{ $loop->index }})">
|
||
<x-icon name="trash" class="h-3.5 w-3.5" />
|
||
</x-modal-trigger>
|
||
@endcan
|
||
@endunless
|
||
</div>
|
||
@empty
|
||
<div class="px-4 py-4 sm:px-5">
|
||
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewall_no_rules') }}</p>
|
||
</div>
|
||
@endforelse
|
||
</div>
|
||
</div>
|
||
@endif
|
||
</x-panel>
|
||
@endif
|
||
|
||
@if ($showF2b)
|
||
{{-- fail2ban-Status --}}
|
||
<x-panel :title="__('servers.fail2ban_title')"
|
||
:subtitle="$f2bActive ? __('servers.fail2ban_sub_active', ['count' => $f2bBanned]) : __('servers.fail2ban_sub_installed_inactive')"
|
||
:padded="false">
|
||
@if ($f2bActive)
|
||
@can('manage-network')
|
||
<x-slot:actions>
|
||
<x-modal-trigger variant="secondary" size="sm" icon title="{{ __('servers.fail2ban_configure') }}"
|
||
component="modals.fail2ban-config" :arguments="['serverId' => $server->id]">
|
||
<x-icon name="settings" class="h-3.5 w-3.5" />
|
||
</x-modal-trigger>
|
||
<x-modal-trigger variant="accent" size="sm"
|
||
component="modals.fail2ban-ban"
|
||
:arguments="['serverId' => $server->id, 'jails' => array_map(fn ($j) => $j['name'], $f2bJails)]">
|
||
<x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_ban_ip') }}
|
||
</x-modal-trigger>
|
||
</x-slot:actions>
|
||
@endcan
|
||
@endif
|
||
|
||
@if ($f2bReadError)
|
||
<div class="flex flex-wrap items-center gap-2.5 px-4 py-4 sm:px-5">
|
||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.fail2ban_read_error') }}</p>
|
||
</div>
|
||
@elseif (! $f2bActive)
|
||
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
|
||
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.fail2ban_state_installed_inactive') }}</p>
|
||
@can('manage-network')
|
||
<x-modal-trigger variant="secondary" size="sm"
|
||
component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'fail2ban', 'enable' => true]">
|
||
{{ __('common.enable') }}
|
||
</x-modal-trigger>
|
||
@endcan
|
||
</div>
|
||
@else
|
||
{{-- Lone fail2ban: status summary + "Gesperrte IPs ansehen" in the left
|
||
column, the whitelist (ignoreip) management in the right. Two-tool
|
||
case keeps the single stacked column. --}}
|
||
<div @class(['lg:grid lg:grid-cols-2 lg:divide-x lg:divide-line' => $lone])>
|
||
{{-- Compact summary; the full jail/banned-IP list (which can grow very
|
||
long) lives in a scrollable modal, opened via the button below. --}}
|
||
<div @class([
|
||
'flex flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-5',
|
||
'border-b border-line lg:border-b-0' => $lone,
|
||
'border-b border-line' => ! $lone,
|
||
])>
|
||
<p class="font-mono text-[11px] text-ink-3">
|
||
<span class="text-online">{{ __('common.active') }}</span>
|
||
· <span @class(['text-offline' => $f2bBanned > 0])>{{ __('servers.fail2ban_banned', ['count' => $f2bBanned]) }}</span>
|
||
· {{ __('servers.fail2ban_jails_count', ['count' => count($f2bJails)]) }}
|
||
</p>
|
||
@can('manage-network')
|
||
<x-modal-trigger variant="secondary" size="sm" class="shrink-0"
|
||
component="modals.fail2ban-bans" :arguments="['serverId' => $server->id]">
|
||
<x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_view_bans') }}
|
||
</x-modal-trigger>
|
||
@endcan
|
||
</div>
|
||
|
||
{{-- Whitelist (ignoreip) --}}
|
||
<div @class([
|
||
'px-4 py-3 sm:px-5',
|
||
'border-t border-line lg:border-t-0' => $lone,
|
||
'border-t border-line' => ! $lone,
|
||
])>
|
||
<p class="mb-2 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.whitelist_label') }}</p>
|
||
<div class="flex flex-wrap gap-1.5">
|
||
@foreach ($f2bIgnore as $ip)
|
||
<span wire:key="f2bi-{{ md5($ip) }}" class="inline-flex items-center gap-1.5 rounded-sm border border-line bg-inset px-2 py-1 font-mono text-[11px] text-ink-2">
|
||
{{ $ip }}
|
||
@unless (in_array($ip, ['127.0.0.1/8', '::1'], true))
|
||
@can('manage-network')
|
||
<button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="{{ __('common.remove') }}">
|
||
<x-icon name="x" class="h-3 w-3" />
|
||
</button>
|
||
@endcan
|
||
@endunless
|
||
</span>
|
||
@endforeach
|
||
</div>
|
||
@can('manage-network')
|
||
<div class="mt-2.5 flex items-center gap-2">
|
||
<input wire:model="newIgnoreIp" wire:keydown.enter="addIgnore" type="text" placeholder="{{ __('servers.whitelist_placeholder') }}"
|
||
class="h-8 w-full max-w-xs rounded-md border border-line bg-inset px-2.5 font-mono text-[11px] text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||
<x-btn variant="secondary" size="sm" wire:click="addIgnore">{{ __('common.add') }}</x-btn>
|
||
</div>
|
||
@endcan
|
||
</div>
|
||
</div>
|
||
@endif
|
||
</x-panel>
|
||
@endif
|
||
</div>
|
||
@endif
|
||
|
||
{{-- Volumes + Netzwerk-Interfaces --}}
|
||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||
<x-panel :title="__('servers.volumes_title')" :subtitle="__('servers.mountpoints', ['count' => count($volumes)])" :padded="false">
|
||
<div class="divide-y divide-line">
|
||
@forelse ($volumes as $vol)
|
||
@php $vt = $tone((int) $vol['used']); @endphp
|
||
<div class="px-4 py-3 sm:px-5">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<p class="truncate font-mono text-sm text-ink">{{ $vol['mount'] }}</p>
|
||
<p class="shrink-0 font-mono text-[11px] text-ink-3">
|
||
<span class="text-ink-2">{{ $vol['fs'] }}</span> · {{ $vol['size'] }}
|
||
</p>
|
||
</div>
|
||
<div class="mt-2 flex items-center gap-3">
|
||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-inset">
|
||
<div class="h-full rounded-full {{ $barColor[$vt] }}" style="width: {{ $vol['used'] }}%"></div>
|
||
</div>
|
||
<span class="tabular w-10 shrink-0 text-right font-mono text-[11px] text-ink-2">{{ $vol['used'] }}%</span>
|
||
</div>
|
||
</div>
|
||
@empty
|
||
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('servers.no_volumes') }}</p>
|
||
@endforelse
|
||
</div>
|
||
</x-panel>
|
||
|
||
<x-panel :title="__('servers.interfaces_title')" :subtitle="__('servers.interfaces_count', ['count' => count($interfaces)])" :padded="false">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full min-w-[34rem] text-left">
|
||
<thead>
|
||
<tr class="border-b border-line font-mono text-[10px] uppercase tracking-wider text-ink-4">
|
||
<th class="px-4 py-2 font-medium sm:px-5">{{ __('servers.col_name') }}</th>
|
||
<th class="px-4 py-2 font-medium">{{ __('servers.col_ip') }}</th>
|
||
<th class="px-4 py-2 font-medium">{{ __('servers.col_mac') }}</th>
|
||
<th class="px-4 py-2 text-right font-medium">{{ __('servers.col_rx') }}</th>
|
||
<th class="px-4 py-2 pr-4 text-right font-medium sm:pr-5">{{ __('servers.col_tx') }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-line">
|
||
@foreach ($interfaces as $if)
|
||
<tr class="font-mono text-[11px] text-ink-2">
|
||
<td class="px-4 py-2.5 text-ink sm:px-5">{{ $if['name'] }}</td>
|
||
<td class="px-4 py-2.5">{{ $if['ip'] }}</td>
|
||
<td class="px-4 py-2.5">{{ $if['mac'] }}</td>
|
||
<td class="tabular px-4 py-2.5 text-right">{{ $if['rx'] }}</td>
|
||
<td class="tabular px-4 py-2.5 pr-4 text-right sm:pr-5">{{ $if['tx'] }}</td>
|
||
</tr>
|
||
@endforeach
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</x-panel>
|
||
</div>
|
||
|
||
{{-- SSH keys --}}
|
||
<x-panel :title="__('servers.ssh_keys_title')" :subtitle="__('servers.ssh_keys_authorized', ['count' => count($sshKeys)])" :padded="false">
|
||
@can('manage-fleet')
|
||
<x-slot:actions>
|
||
<x-modal-trigger variant="accent" component="modals.add-ssh-key" :arguments="['serverId' => $server->id]">
|
||
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.ssh_keys_add') }}
|
||
</x-modal-trigger>
|
||
</x-slot:actions>
|
||
@endcan
|
||
<div class="divide-y divide-line">
|
||
@forelse ($sshKeys as $key)
|
||
<div wire:key="sshk-{{ md5($key['fingerprint'] ?? $loop->index) }}" class="flex items-center gap-3 px-4 py-3 sm:px-5">
|
||
<span class="grid h-7 w-7 shrink-0 place-items-center rounded-sm bg-raised text-ink-3">
|
||
<x-icon name="shield" class="h-3.5 w-3.5" />
|
||
</span>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<p class="truncate font-mono text-sm text-ink">{{ $key['comment'] }}</p>
|
||
<x-badge tone="neutral" class="shrink-0">{{ $key['type'] }}</x-badge>
|
||
</div>
|
||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
|
||
</div>
|
||
@can('manage-fleet')
|
||
<x-modal-trigger variant="danger-soft" icon action="confirmKeyRemoval({{ $loop->index }})" title="{{ __('servers.ssh_keys_remove') }}">
|
||
<x-icon name="trash" class="h-3.5 w-3.5" />
|
||
</x-modal-trigger>
|
||
@endcan
|
||
</div>
|
||
@empty
|
||
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('servers.ssh_keys_none') }}</p>
|
||
@endforelse
|
||
</div>
|
||
</x-panel>
|
||
@else
|
||
{{-- skeletons for the SSH-loaded sections --}}
|
||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||
@foreach ([__('servers.specs_title'), __('servers.security_title')] as $t)
|
||
<x-panel :title="$t" :subtitle="__('servers.loading')" :padded="false">
|
||
<div class="space-y-3 p-4 sm:p-5">
|
||
<x-skeleton class="h-3 w-full" /><x-skeleton class="h-3 w-2/3" /><x-skeleton class="h-3 w-5/6" /><x-skeleton class="h-3 w-1/2" />
|
||
</div>
|
||
</x-panel>
|
||
@endforeach
|
||
</div>
|
||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||
@foreach ([__('servers.volumes_title'), __('servers.interfaces_title')] as $t)
|
||
<x-panel :title="$t" :subtitle="__('servers.loading')" :padded="false">
|
||
<div class="space-y-3 p-4 sm:p-5">
|
||
<x-skeleton class="h-3 w-full" /><x-skeleton class="h-3 w-3/4" /><x-skeleton class="h-3 w-5/6" />
|
||
</div>
|
||
</x-panel>
|
||
@endforeach
|
||
</div>
|
||
<x-panel :title="__('servers.ssh_keys_title')" :subtitle="__('servers.loading')" :padded="false">
|
||
<div class="space-y-3 p-4 sm:p-5"><x-skeleton class="h-3 w-2/3" /><x-skeleton class="h-3 w-1/2" /></div>
|
||
</x-panel>
|
||
@endif
|
||
@endif {{-- /tab === overview --}}
|
||
</div>
|