clusev/resources/views/livewire/servers/show.blade.php

723 lines
49 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

@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" 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>