180 lines
10 KiB
PHP
180 lines
10 KiB
PHP
<div class="space-y-4">
|
|
{{-- Header --}}
|
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
|
<div>
|
|
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('threats.eyebrow') }}</p>
|
|
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('threats.title') }}</h2>
|
|
</div>
|
|
<x-status-pill :status="$probes_24h > 0 ? 'warning' : 'online'">
|
|
{{ __('threats.probes_pill', ['count' => $probes_24h]) }}
|
|
</x-status-pill>
|
|
</div>
|
|
|
|
{{-- KPI strip: auto-fit — login attempts first (the "someone tried to log in" signal). --}}
|
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
<x-kpi
|
|
:label="__('threats.kpi_login_attempts')"
|
|
:value="$login_attempts_24h"
|
|
:tone="$login_attempts_24h > 0 ? 'offline' : 'ink'"
|
|
icon="log-in" />
|
|
<x-kpi
|
|
:label="__('threats.kpi_probes')"
|
|
:value="$probes_24h"
|
|
:tone="$probes_24h > 0 ? 'warning' : 'ink'"
|
|
icon="shield-alert" />
|
|
<x-kpi
|
|
:label="__('threats.kpi_banned')"
|
|
:value="$banned_total"
|
|
:tone="$banned_total > 0 ? 'offline' : 'ink'"
|
|
icon="lock" />
|
|
<x-kpi
|
|
:label="__('threats.kpi_honeytoken')"
|
|
:value="$honeytoken_trips"
|
|
:tone="$honeytoken_trips > 0 ? 'offline' : 'ink'"
|
|
icon="alert" />
|
|
<x-kpi
|
|
:label="__('threats.kpi_top_ip')"
|
|
:value="$top_ip ?? '—'"
|
|
:tone="$top_ip ? 'warning' : 'ink'"
|
|
icon="activity" />
|
|
</div>
|
|
|
|
{{-- Threat feed --}}
|
|
<x-panel :title="__('threats.feed_title')" :subtitle="__('threats.feed_subtitle')" :padded="false">
|
|
<x-slot:actions>
|
|
<label class="relative block">
|
|
<span class="sr-only">{{ __('threats.search_label') }}</span>
|
|
<span class="pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-ink-4">
|
|
<x-icon name="search" class="h-3.5 w-3.5" />
|
|
</span>
|
|
<input
|
|
type="search"
|
|
data-page-search
|
|
wire:model.live.debounce.300ms="q"
|
|
placeholder="{{ __('threats.search_placeholder') }}"
|
|
class="min-h-11 w-44 rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-64"
|
|
/>
|
|
</label>
|
|
</x-slot:actions>
|
|
|
|
<div class="divide-y divide-line">
|
|
@forelse ($events as $e)
|
|
<div wire:key="threat-{{ $e->uuid }}" class="flex items-start gap-3 px-4 py-3 sm:px-5">
|
|
<span @class([
|
|
'mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-sm',
|
|
'bg-offline/10 text-offline' => $e->is_error,
|
|
'bg-raised text-ink-3' => ! $e->is_error,
|
|
])>
|
|
<x-icon :name="$e->is_error ? 'shield-alert' : 'audit'" class="h-3.5 w-3.5" />
|
|
</span>
|
|
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm text-ink">
|
|
<span @class(['font-medium', 'text-offline' => $e->is_error, 'text-ink' => ! $e->is_error])>{{ $e->action_label }}</span>
|
|
</p>
|
|
@if ($e->ip)
|
|
<p class="truncate font-mono text-[11px] text-ink-2">{{ $e->ip }}</p>
|
|
@endif
|
|
@if ($e->target)
|
|
<p class="truncate font-mono text-[11px] text-ink-3">{{ $e->target }}</p>
|
|
@endif
|
|
@php
|
|
// Attacker-guessed credentials captured on a fake-login POST. Values are
|
|
// ATTACKER-controlled — rendered via {{ }} so Blade auto-escapes them.
|
|
$triedUser = data_get($e->meta, 'tried_user');
|
|
$triedPass = data_get($e->meta, 'tried_pass');
|
|
@endphp
|
|
@if (! is_null($triedUser) || ! is_null($triedPass))
|
|
<p class="truncate font-mono text-[11px] text-ink-4">
|
|
{{ __('threats.tried_creds') }}: {{ $triedUser ?? '—' }} / {{ $triedPass ?? '—' }}
|
|
</p>
|
|
@endif
|
|
</div>
|
|
|
|
<span class="shrink-0 whitespace-nowrap font-mono text-[11px] text-ink-4">
|
|
{{ $e->created_at->diffForHumans() }}
|
|
</span>
|
|
</div>
|
|
@empty
|
|
<div class="flex flex-col items-center gap-2 px-4 py-12 text-center sm:px-5">
|
|
<span class="grid h-10 w-10 place-items-center rounded-md bg-raised text-ink-4">
|
|
<x-icon name="shield-check" class="h-5 w-5" />
|
|
</span>
|
|
<p class="font-display text-sm font-semibold text-ink-2">{{ __('threats.empty_title') }}</p>
|
|
<p class="max-w-xs text-[11px] text-ink-4">
|
|
@if (trim($q) !== '')
|
|
{{ __('threats.empty_filtered', ['query' => $q]) }}
|
|
@else
|
|
{{ __('threats.empty_feed') }}
|
|
@endif
|
|
</p>
|
|
</div>
|
|
@endforelse
|
|
</div>
|
|
|
|
{{-- Pagination — page is deep-linked via ?page= (Livewire WithPagination). --}}
|
|
@if ($events->lastPage() > 1)
|
|
<nav aria-label="{{ __('threats.pagination_nav') }}" class="flex items-center justify-between gap-3 border-t border-line px-4 py-3 sm:px-5">
|
|
<button type="button" wire:click="previousPage" @disabled($events->onFirstPage())
|
|
class="inline-flex items-center gap-1 rounded-md border border-line bg-raised px-2.5 py-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/30 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:text-ink-2 lg:py-1.5">
|
|
<x-icon name="chevron-left" class="h-3.5 w-3.5" />
|
|
<span class="hidden sm:inline">{{ __('threats.pagination_prev') }}</span>
|
|
</button>
|
|
|
|
<div class="flex items-center gap-1">
|
|
@foreach ($pageWindow as $p)
|
|
@if ($p === '…')
|
|
<span class="px-1 font-mono text-[11px] text-ink-4" aria-hidden="true">…</span>
|
|
@else
|
|
<button type="button" wire:key="threat-page-{{ $p }}" wire:click="gotoPage({{ $p }})"
|
|
@class([
|
|
'grid h-11 min-w-11 place-items-center rounded-md border px-2 font-mono text-[11px] tabular-nums transition-colors lg:h-7 lg:min-w-[28px]',
|
|
'border-accent/40 bg-accent/15 text-accent-text' => $p === $events->currentPage(),
|
|
'border-line bg-raised text-ink-2 hover:border-accent/30 hover:text-ink' => $p !== $events->currentPage(),
|
|
])
|
|
@if ($p === $events->currentPage()) aria-current="page" @endif>{{ $p }}</button>
|
|
@endif
|
|
@endforeach
|
|
</div>
|
|
|
|
<button type="button" wire:click="nextPage" @disabled(! $events->hasMorePages())
|
|
class="inline-flex items-center gap-1 rounded-md border border-line bg-raised px-2.5 py-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/30 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:text-ink-2 lg:py-1.5">
|
|
<span class="hidden sm:inline">{{ __('threats.pagination_next') }}</span>
|
|
<x-icon name="chevron-right" class="h-3.5 w-3.5" />
|
|
</button>
|
|
</nav>
|
|
@endif
|
|
</x-panel>
|
|
|
|
{{-- Banned-IP management --}}
|
|
<x-panel :title="__('threats.banned_title')" :subtitle="__('threats.banned_subtitle')">
|
|
<div class="divide-y divide-line">
|
|
@forelse ($bannedIps as $ban)
|
|
<div wire:key="banned-{{ $ban->getKey() }}" class="flex flex-wrap items-center gap-3 py-3 first:pt-0 last:pb-0">
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate font-mono text-sm text-ink">{{ $ban->ip }}</p>
|
|
<p class="mt-1 flex flex-wrap items-center gap-2 font-mono text-[11px] text-ink-3">
|
|
<x-badge tone="warning">{{ $ban->reason ?? __('threats.reason_unknown') }}</x-badge>
|
|
<span>{{ __('threats.banned_until', ['time' => $ban->banned_until->diffForHumans()]) }}</span>
|
|
</p>
|
|
</div>
|
|
@can('manage-panel')
|
|
<x-btn variant="secondary" class="shrink-0" wire:click="unban('{{ $ban->ip }}')" wire:loading.attr="disabled">
|
|
<x-icon name="shield-off" class="h-3.5 w-3.5" />
|
|
{{ __('threats.unban') }}
|
|
</x-btn>
|
|
@endcan
|
|
</div>
|
|
@empty
|
|
<div class="flex flex-col items-center gap-2 py-8 text-center">
|
|
<span class="grid h-10 w-10 place-items-center rounded-md bg-raised text-ink-4">
|
|
<x-icon name="shield-check" class="h-5 w-5" />
|
|
</span>
|
|
<p class="font-display text-sm font-semibold text-ink-2">{{ __('threats.empty_banned_title') }}</p>
|
|
<p class="max-w-xs text-[11px] text-ink-4">{{ __('threats.empty_banned') }}</p>
|
|
</div>
|
|
@endforelse
|
|
</div>
|
|
</x-panel>
|
|
</div>
|