mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(analytics): start Locations card on resource analytics tabs
Key the loaded view so the lazy-load morph replaces the placeholder skeleton instead of patching it in place. Alpine did not start the Locations toggle, the globe, and the table pagers on reused nodes. Closes #12370 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
623d367a2a
commit
3048e9948c
@@ -39,192 +39,197 @@ $approxBadge = fn (string $tooltip) => '<span title="'.e($tooltip).'" class="ml-
|
||||
icon-name="network" />
|
||||
</x-application.settings-section>
|
||||
@else
|
||||
@if ($live)
|
||||
<div wire:poll.60s="loadData" class="hidden"></div>
|
||||
@endif
|
||||
{{-- Keyed so the lazy-load morph replaces the placeholder skeleton, which has the same
|
||||
sections, instead of patching it in place: Alpine does not start the x-data
|
||||
components (Locations toggle and globe, table pagers) on reused skeleton nodes. --}}
|
||||
<div wire:key="analytics-loaded" class="flex flex-col gap-6">
|
||||
@if ($live)
|
||||
<div wire:poll.60s="loadData" class="hidden"></div>
|
||||
@endif
|
||||
|
||||
<x-application.settings-section id="analytics-range-section" title="Analytics"
|
||||
helper="Inspect traffic statistics reported by Sentinel.">
|
||||
<x-slot:actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative w-full transition-opacity sm:w-44"
|
||||
wire:loading.class="pointer-events-none opacity-60" wire:target="setRange">
|
||||
<x-forms.listbox id="range" onChange="setRange" :onChangeArgs="[]" :options="$rangeListboxOptions" />
|
||||
<div class="absolute inset-0 hidden items-center justify-center rounded-lg bg-white/70 dark:bg-base/70"
|
||||
wire:loading.flex wire:target="setRange">
|
||||
<x-loading compact aria-label="Loading analytics" />
|
||||
<x-application.settings-section id="analytics-range-section" title="Analytics"
|
||||
helper="Inspect traffic statistics reported by Sentinel.">
|
||||
<x-slot:actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative w-full transition-opacity sm:w-44"
|
||||
wire:loading.class="pointer-events-none opacity-60" wire:target="setRange">
|
||||
<x-forms.listbox id="range" onChange="setRange" :onChangeArgs="[]" :options="$rangeListboxOptions" />
|
||||
<div class="absolute inset-0 hidden items-center justify-center rounded-lg bg-white/70 dark:bg-base/70"
|
||||
wire:loading.flex wire:target="setRange">
|
||||
<x-loading compact aria-label="Loading analytics" />
|
||||
</div>
|
||||
</div>
|
||||
<x-live-toggle :active="$live" storageKey="traffic-live" title="Turn live refresh on or off (updates every 60 seconds)" />
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-neutral-200 sm:grid-cols-3 lg:grid-cols-5 dark:bg-white/[0.07]">
|
||||
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Requests</span>
|
||||
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg" title="{{ number_format($overview['requests'] ?? 0) }} requests">{{ compactNumber($overview['requests'] ?? 0) }}</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-requests',
|
||||
'initial' => $this->requestsSpark(),
|
||||
'colorVar' => '--chart-status-3xx',
|
||||
'event' => 'refreshChartData-'.$chartId.'-status',
|
||||
'key' => 'requestsSpark',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
<x-live-toggle :active="$live" storageKey="traffic-live" title="Turn live refresh on or off (updates every 60 seconds)" />
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-neutral-200 sm:grid-cols-3 lg:grid-cols-5 dark:bg-white/[0.07]">
|
||||
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Requests</span>
|
||||
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg" title="{{ number_format($overview['requests'] ?? 0) }} requests">{{ compactNumber($overview['requests'] ?? 0) }}</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-requests',
|
||||
'initial' => $this->requestsSpark(),
|
||||
'colorVar' => '--chart-status-3xx',
|
||||
'event' => 'refreshChartData-'.$chartId.'-status',
|
||||
'key' => 'requestsSpark',
|
||||
])
|
||||
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
|
||||
<span class="flex items-center text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
|
||||
Unique visitors
|
||||
@if ($uniquesApproximate)
|
||||
{!! $approxBadge('Summed across services; visitors of more than one service may be double-counted.') !!}
|
||||
@endif
|
||||
</span>
|
||||
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg" title="{{ number_format($overview['uniqueVisitors'] ?? 0) }} unique visitors">{{ compactNumber($overview['uniqueVisitors'] ?? 0) }}</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-visitors',
|
||||
'initial' => $this->uniquesSpark(),
|
||||
'colorVar' => '--chart-status-2xx',
|
||||
'event' => 'refreshChartData-'.$chartId.'-status',
|
||||
'key' => 'uniquesSpark',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Bandwidth</span>
|
||||
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ formatBytes($this->bandwidthBytes()) }}</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-bandwidth',
|
||||
'initial' => $this->bandwidthSpark(),
|
||||
'colorVar' => '--chart-spark-bandwidth',
|
||||
'event' => 'refreshChartData-'.$chartId.'-status',
|
||||
'key' => 'bandwidthSpark',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Error rate</span>
|
||||
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ $this->errorRate() }}%</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-errors',
|
||||
'initial' => $this->errorsSpark(),
|
||||
'colorVar' => '--chart-status-5xx',
|
||||
'event' => 'refreshChartData-'.$chartId.'-status',
|
||||
'key' => 'errorsSpark',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 flex flex-col bg-[var(--coollabs-base)] px-4 py-3 sm:col-span-1">
|
||||
<span class="flex items-center text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
|
||||
p95 latency
|
||||
@if ($latencyApproximate)
|
||||
{!! $approxBadge('Highest p95 latency across services; not a true merged percentile.') !!}
|
||||
@endif
|
||||
</span>
|
||||
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ formatLatency($overview['latencyP95'] ?? 0) }}</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-latency',
|
||||
'initial' => $this->latencySpark(),
|
||||
'colorVar' => '--chart-status-4xx',
|
||||
'event' => 'refreshChartData-'.$chartId.'-status',
|
||||
'key' => 'latencySpark',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
|
||||
<span class="flex items-center text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
|
||||
Unique visitors
|
||||
@if ($uniquesApproximate)
|
||||
{!! $approxBadge('Summed across services; visitors of more than one service may be double-counted.') !!}
|
||||
@endif
|
||||
</span>
|
||||
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg" title="{{ number_format($overview['uniqueVisitors'] ?? 0) }} unique visitors">{{ compactNumber($overview['uniqueVisitors'] ?? 0) }}</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-visitors',
|
||||
'initial' => $this->uniquesSpark(),
|
||||
'colorVar' => '--chart-status-2xx',
|
||||
'event' => 'refreshChartData-'.$chartId.'-status',
|
||||
'key' => 'uniquesSpark',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Bandwidth</span>
|
||||
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ formatBytes($this->bandwidthBytes()) }}</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-bandwidth',
|
||||
'initial' => $this->bandwidthSpark(),
|
||||
'colorVar' => '--chart-spark-bandwidth',
|
||||
'event' => 'refreshChartData-'.$chartId.'-status',
|
||||
'key' => 'bandwidthSpark',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Error rate</span>
|
||||
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ $this->errorRate() }}%</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-errors',
|
||||
'initial' => $this->errorsSpark(),
|
||||
'colorVar' => '--chart-status-5xx',
|
||||
'event' => 'refreshChartData-'.$chartId.'-status',
|
||||
'key' => 'errorsSpark',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 flex flex-col bg-[var(--coollabs-base)] px-4 py-3 sm:col-span-1">
|
||||
<span class="flex items-center text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
|
||||
p95 latency
|
||||
@if ($latencyApproximate)
|
||||
{!! $approxBadge('Highest p95 latency across services; not a true merged percentile.') !!}
|
||||
@endif
|
||||
</span>
|
||||
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ formatLatency($overview['latencyP95'] ?? 0) }}</span>
|
||||
<div class="mt-auto pt-3">
|
||||
@include('livewire.traffic._sparkline', [
|
||||
'id' => $chartId.'-spark-latency',
|
||||
'initial' => $this->latencySpark(),
|
||||
'colorVar' => '--chart-status-4xx',
|
||||
'event' => 'refreshChartData-'.$chartId.'-status',
|
||||
'key' => 'latencySpark',
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="analytics-requests-section" title="Requests" flush
|
||||
helper="Total request volume over time for the selected range.">
|
||||
@include('livewire.traffic._requests-chart')
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="analytics-status-codes-section" title="Status codes"
|
||||
helper="Share of responses by HTTP status class for the selected range.">
|
||||
@include('livewire.traffic._status-codes')
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="analytics-paths-section" title="Top paths"
|
||||
helper="Most requested paths for the selected range." flush>
|
||||
@include('livewire.traffic._paths-list', ['paths' => $topPaths])
|
||||
</x-application.settings-section>
|
||||
|
||||
@include('livewire.traffic._geo', [
|
||||
'countries' => data_get($breakdowns, 'country', []),
|
||||
'cities' => trafficCityRows(data_get($breakdowns, 'city', [])),
|
||||
'attribution' => $attribution,
|
||||
])
|
||||
|
||||
{{-- Requests by device type (donut) + HTTP versions / cache / status. --}}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<x-application.settings-section id="analytics-device-section" title="Requests by device type"
|
||||
helper="Share of requests by client device class for the selected range.">
|
||||
@php $deviceChart = $this->deviceChartData(); @endphp
|
||||
@include('livewire.traffic._device-chart', [
|
||||
'labels' => $deviceChart['labels'],
|
||||
'series' => $deviceChart['series'],
|
||||
])
|
||||
</x-application.settings-section>
|
||||
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => 'protocol',
|
||||
'label' => 'Top HTTP versions',
|
||||
'rows' => data_get($breakdowns, 'protocol', []),
|
||||
'helper' => 'Request volume by negotiated HTTP protocol version.',
|
||||
])
|
||||
</div>
|
||||
<x-application.settings-section id="analytics-requests-section" title="Requests" flush
|
||||
helper="Total request volume over time for the selected range.">
|
||||
@include('livewire.traffic._requests-chart')
|
||||
</x-application.settings-section>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => 'cache',
|
||||
'label' => 'Top cache statuses',
|
||||
'rows' => data_get($breakdowns, 'cache', []),
|
||||
'helper' => 'Reverse-proxy cache outcome (hit, miss, bypass, …) by request count.',
|
||||
<x-application.settings-section id="analytics-status-codes-section" title="Status codes"
|
||||
helper="Share of responses by HTTP status class for the selected range.">
|
||||
@include('livewire.traffic._status-codes')
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="analytics-paths-section" title="Top paths"
|
||||
helper="Most requested paths for the selected range." flush>
|
||||
@include('livewire.traffic._paths-list', ['paths' => $topPaths])
|
||||
</x-application.settings-section>
|
||||
|
||||
@include('livewire.traffic._geo', [
|
||||
'countries' => data_get($breakdowns, 'country', []),
|
||||
'cities' => trafficCityRows(data_get($breakdowns, 'city', [])),
|
||||
'attribution' => $attribution,
|
||||
])
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => 'status',
|
||||
'label' => 'Top status codes',
|
||||
'rows' => data_get($breakdowns, 'status', []),
|
||||
'helper' => 'Most frequent HTTP response status codes for the selected range.',
|
||||
])
|
||||
</div>
|
||||
|
||||
{{-- Requests by device type (donut) + HTTP versions / cache / status. --}}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<x-application.settings-section id="analytics-device-section" title="Requests by device type"
|
||||
helper="Share of requests by client device class for the selected range.">
|
||||
@php $deviceChart = $this->deviceChartData(); @endphp
|
||||
@include('livewire.traffic._device-chart', [
|
||||
'labels' => $deviceChart['labels'],
|
||||
'series' => $deviceChart['series'],
|
||||
])
|
||||
</x-application.settings-section>
|
||||
|
||||
{{-- Referrers / browsers / OS / AI agents / IPs, two per row. --}}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
@foreach ($dimensionLabels as $dimension => $label)
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => $dimension,
|
||||
'label' => $label,
|
||||
'rows' => data_get($breakdowns, $dimension, []),
|
||||
'dimension' => 'protocol',
|
||||
'label' => 'Top HTTP versions',
|
||||
'rows' => data_get($breakdowns, 'protocol', []),
|
||||
'helper' => 'Request volume by negotiated HTTP protocol version.',
|
||||
])
|
||||
@endforeach
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => 'agent',
|
||||
'label' => 'AI agents & bots',
|
||||
'rows' => data_get($breakdowns, 'agent', []),
|
||||
'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, …) by request count.',
|
||||
])
|
||||
@php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full)
|
||||
@if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off)
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => 'ip',
|
||||
'label' => $ipModeEnum->breakdownLabel(),
|
||||
'rows' => data_get($breakdowns, 'ip', []),
|
||||
'helper' => $ipModeEnum->breakdownHelper(),
|
||||
])
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- User agents (full width — raw UA strings are long). --}}
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => 'useragent',
|
||||
'label' => 'Top user agents',
|
||||
'rows' => data_get($breakdowns, 'useragent', []),
|
||||
'helper' => 'Most frequent raw User-Agent strings for the selected range.',
|
||||
])
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => 'cache',
|
||||
'label' => 'Top cache statuses',
|
||||
'rows' => data_get($breakdowns, 'cache', []),
|
||||
'helper' => 'Reverse-proxy cache outcome (hit, miss, bypass, …) by request count.',
|
||||
])
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => 'status',
|
||||
'label' => 'Top status codes',
|
||||
'rows' => data_get($breakdowns, 'status', []),
|
||||
'helper' => 'Most frequent HTTP response status codes for the selected range.',
|
||||
])
|
||||
</div>
|
||||
|
||||
{{-- Referrers / browsers / OS / AI agents / IPs, two per row. --}}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
@foreach ($dimensionLabels as $dimension => $label)
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => $dimension,
|
||||
'label' => $label,
|
||||
'rows' => data_get($breakdowns, $dimension, []),
|
||||
])
|
||||
@endforeach
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => 'agent',
|
||||
'label' => 'AI agents & bots',
|
||||
'rows' => data_get($breakdowns, 'agent', []),
|
||||
'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, …) by request count.',
|
||||
])
|
||||
@php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full)
|
||||
@if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off)
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => 'ip',
|
||||
'label' => $ipModeEnum->breakdownLabel(),
|
||||
'rows' => data_get($breakdowns, 'ip', []),
|
||||
'helper' => $ipModeEnum->breakdownHelper(),
|
||||
])
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- User agents (full width — raw UA strings are long). --}}
|
||||
@include('livewire.traffic._breakdown-section', [
|
||||
'dimension' => 'useragent',
|
||||
'label' => 'Top user agents',
|
||||
'rows' => data_get($breakdowns, 'useragent', []),
|
||||
'helper' => 'Most frequent raw User-Agent strings for the selected range.',
|
||||
])
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,9 @@ class FakeBrowserCityTrafficClient extends SentinelTrafficClient
|
||||
'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
|
||||
'unique_visitors' => 320,
|
||||
],
|
||||
'/traffic/paths' => [
|
||||
['path' => '/', 'requests' => 900, 'bytes_out' => 20000],
|
||||
],
|
||||
'/traffic/breakdown/country' => [
|
||||
['value' => 'DE', 'requests' => 600, 'bytes_out' => 15000],
|
||||
['value' => 'FR', 'requests' => 400, 'bytes_out' => 10000],
|
||||
@@ -65,3 +68,24 @@ it('switches the Locations card between countries and cities and remembers the s
|
||||
->assertNoJavaScriptErrors()
|
||||
->screenshot(filename: 'analytics-locations-cities');
|
||||
});
|
||||
|
||||
// The lazy placeholder of this tab has the same sections as the loaded view. The Top paths
|
||||
// row makes the loaded view start Alpine components, which a morph of the skeleton broke.
|
||||
it('switches the Locations card between countries and cities on the application analytics tab', function () {
|
||||
$application = createBrowserApplication($this->stack, ['uuid' => 'app-geo-cities', 'name' => 'Geo App']);
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$url = applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $application);
|
||||
|
||||
$page = visit("{$url}/analytics");
|
||||
|
||||
$page->assertSee('Locations')
|
||||
->assertSee('Germany')
|
||||
->assertDontSee('BE, Germany')
|
||||
->click('button:visible:has-text("Cities")')
|
||||
->assertSee('BE, Germany')
|
||||
->assertSee('IDF, France')
|
||||
->assertScript("Boolean(document.getElementById('application-analytics-globe')._trafficGlobe)")
|
||||
->assertNoJavaScriptErrors()
|
||||
->screenshot(filename: 'application-analytics-locations-cities');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user