Featured artworks thumbnails
This commit is contained in:
290
resources/views/moderation/traffic/online.blade.php
Normal file
290
resources/views/moderation/traffic/online.blade.php
Normal file
@@ -0,0 +1,290 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
$initialPayload = [
|
||||
'summary' => $summary,
|
||||
'visitors' => $visitors,
|
||||
'active_pages' => $activePages,
|
||||
'generated_at' => $generatedAt,
|
||||
];
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
<style>
|
||||
body.page-moderation main { padding-top: 4rem; }
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.body.classList.add('page-moderation')
|
||||
})
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<section class="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8 text-zinc-100">
|
||||
<div class="flex flex-col gap-4 rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/40">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.24em] text-cyan-300/80">Moderation Traffic</div>
|
||||
<h1 class="mt-2 text-3xl font-semibold text-white">Online Visitors</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-zinc-300">Live view of logged users, guests, crawlers, AI bots, and suspicious traffic.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-zinc-400">
|
||||
<a href="{{ url('/moderation') }}" class="inline-flex items-center rounded-full border border-white/10 px-4 py-2 text-zinc-200 transition hover:border-cyan-300/40 hover:text-cyan-200">Back to moderation</a>
|
||||
<span id="online-generated-at" class="rounded-full border border-white/10 bg-white/5 px-4 py-2">Updated {{ $generatedAt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="online-summary" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-7"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1.7fr_1fr]">
|
||||
<div class="rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/30">
|
||||
<div class="flex flex-col gap-4 border-b border-white/10 pb-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Visitors</h2>
|
||||
<p class="mt-1 text-sm text-zinc-400">Filter the live table without leaving the page.</p>
|
||||
</div>
|
||||
<div id="visitor-filters" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-white/10 text-sm">
|
||||
<thead class="text-left text-xs uppercase tracking-[0.18em] text-zinc-500">
|
||||
<tr>
|
||||
<th class="px-3 py-3 font-medium">Type</th>
|
||||
<th class="px-3 py-3 font-medium">User</th>
|
||||
<th class="px-3 py-3 font-medium">IP</th>
|
||||
<th class="px-3 py-3 font-medium">Bot / Browser</th>
|
||||
<th class="px-3 py-3 font-medium">Current URL</th>
|
||||
<th class="px-3 py-3 font-medium">Referer</th>
|
||||
<th class="px-3 py-3 font-medium">First Seen</th>
|
||||
<th class="px-3 py-3 font-medium">Last Seen</th>
|
||||
<th class="px-3 py-3 font-medium">Hits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="online-visitors-table" class="divide-y divide-white/5 text-zinc-200"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="rounded-3xl border border-white/10 bg-slate-950/70 p-6 shadow-2xl shadow-slate-950/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Active Pages</h2>
|
||||
<p class="mt-1 text-sm text-zinc-400">Current URLs with live visitor counts.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="online-active-pages" class="mt-5 space-y-3"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const dataUrl = @json($dataUrl);
|
||||
const initialState = @json($initialPayload);
|
||||
const summaryContainer = document.getElementById('online-summary');
|
||||
const filtersContainer = document.getElementById('visitor-filters');
|
||||
const tableBody = document.getElementById('online-visitors-table');
|
||||
const activePagesContainer = document.getElementById('online-active-pages');
|
||||
const generatedAtContainer = document.getElementById('online-generated-at');
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
|
||||
const summaryCards = [
|
||||
{ key: 'total', label: 'Online now' },
|
||||
{ key: 'logged', label: 'Logged users' },
|
||||
{ key: 'guests', label: 'Guests' },
|
||||
{ key: 'bots', label: 'Bots' },
|
||||
{ key: 'search_bots', label: 'Search bots' },
|
||||
{ key: 'ai_bots', label: 'AI bots' },
|
||||
{ key: 'suspicious_bots', label: 'Suspicious' },
|
||||
];
|
||||
|
||||
const filterDefinitions = [
|
||||
{ key: 'all', label: 'All', matches: () => true },
|
||||
{ key: 'logged', label: 'Logged', matches: (visitor) => visitor.type === 'human_logged' },
|
||||
{ key: 'guests', label: 'Guests', matches: (visitor) => visitor.type === 'human_guest' },
|
||||
{ key: 'bots', label: 'Bots', matches: (visitor) => String(visitor.type || '').endsWith('_bot') },
|
||||
{ key: 'search_bot', label: 'Search bots', matches: (visitor) => visitor.type === 'search_bot' },
|
||||
{ key: 'ai_bot', label: 'AI bots', matches: (visitor) => visitor.type === 'ai_bot' },
|
||||
{ key: 'social_bot', label: 'Social bots', matches: (visitor) => visitor.type === 'social_bot' },
|
||||
{ key: 'seo_bot', label: 'SEO bots', matches: (visitor) => visitor.type === 'seo_bot' },
|
||||
{ key: 'suspicious_bot', label: 'Suspicious', matches: (visitor) => visitor.type === 'suspicious_bot' },
|
||||
];
|
||||
|
||||
const typeLabels = {
|
||||
human_logged: 'Logged',
|
||||
human_guest: 'Guest',
|
||||
search_bot: 'Search Bot',
|
||||
ai_bot: 'AI Bot',
|
||||
social_bot: 'Social Bot',
|
||||
seo_bot: 'SEO Bot',
|
||||
monitoring_bot: 'Monitoring Bot',
|
||||
suspicious_bot: 'Suspicious',
|
||||
unknown_bot: 'Unknown Bot',
|
||||
};
|
||||
|
||||
const typeClasses = {
|
||||
human_logged: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200',
|
||||
human_guest: 'border-sky-400/30 bg-sky-400/10 text-sky-200',
|
||||
search_bot: 'border-indigo-400/30 bg-indigo-400/10 text-indigo-200',
|
||||
ai_bot: 'border-fuchsia-400/30 bg-fuchsia-400/10 text-fuchsia-200',
|
||||
social_bot: 'border-amber-400/30 bg-amber-400/10 text-amber-200',
|
||||
seo_bot: 'border-orange-400/30 bg-orange-400/10 text-orange-200',
|
||||
monitoring_bot: 'border-teal-400/30 bg-teal-400/10 text-teal-200',
|
||||
suspicious_bot: 'border-rose-400/30 bg-rose-400/10 text-rose-200',
|
||||
unknown_bot: 'border-zinc-400/30 bg-zinc-400/10 text-zinc-200',
|
||||
};
|
||||
|
||||
let activeFilter = 'all';
|
||||
let state = initialState;
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : dateFormatter.format(parsed);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
summaryContainer.innerHTML = summaryCards.map((card) => `
|
||||
<article class="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div class="text-xs uppercase tracking-[0.18em] text-zinc-500">${card.label}</div>
|
||||
<div class="mt-3 text-3xl font-semibold text-white">${state.summary?.[card.key] ?? 0}</div>
|
||||
</article>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderFilters() {
|
||||
filtersContainer.innerHTML = filterDefinitions.map((filter) => {
|
||||
const isActive = filter.key === activeFilter;
|
||||
return `<button type="button" data-filter="${filter.key}" class="rounded-full border px-3 py-1.5 text-sm transition ${isActive ? 'border-cyan-300/50 bg-cyan-300/10 text-cyan-100' : 'border-white/10 bg-white/[0.03] text-zinc-300 hover:border-white/20 hover:text-white'}">${filter.label}</button>`;
|
||||
}).join('');
|
||||
|
||||
filtersContainer.querySelectorAll('[data-filter]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
activeFilter = button.getAttribute('data-filter') || 'all';
|
||||
renderFilters();
|
||||
renderVisitors();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function filteredVisitors() {
|
||||
const currentFilter = filterDefinitions.find((filter) => filter.key === activeFilter) || filterDefinitions[0];
|
||||
return (state.visitors || []).filter(currentFilter.matches);
|
||||
}
|
||||
|
||||
function renderVisitors() {
|
||||
const visitors = filteredVisitors();
|
||||
|
||||
if (visitors.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="9" class="px-3 py-8 text-center text-zinc-500">No visitors match the current filter.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableBody.innerHTML = visitors.map((visitor) => {
|
||||
const type = String(visitor.type || 'unknown_bot');
|
||||
const badgeClass = typeClasses[type] || typeClasses.unknown_bot;
|
||||
const badgeLabel = typeLabels[type] || 'Unknown';
|
||||
const agentLabel = visitor.bot_family || visitor.browser || 'Unknown';
|
||||
const userLabel = visitor.user_name || (type === 'human_guest' ? 'Guest visitor' : 'Anonymous');
|
||||
const currentUrl = visitor.current_url || '/';
|
||||
const referer = visitor.referer || '—';
|
||||
|
||||
return `
|
||||
<tr class="align-top">
|
||||
<td class="px-3 py-4"><span class="inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold ${badgeClass}">${badgeLabel}</span></td>
|
||||
<td class="px-3 py-4">
|
||||
<div class="font-medium text-white">${escapeHtml(userLabel)}</div>
|
||||
<div class="mt-1 text-xs text-zinc-500">${visitor.user_id ? `User #${escapeHtml(visitor.user_id)}` : 'No account'}</div>
|
||||
</td>
|
||||
<td class="px-3 py-4 font-mono text-xs text-zinc-300">${escapeHtml(visitor.ip_masked || 'unknown')}</td>
|
||||
<td class="px-3 py-4">
|
||||
<div class="font-medium text-white">${escapeHtml(agentLabel)}</div>
|
||||
<div class="mt-1 text-xs text-zinc-500">${escapeHtml(visitor.browser || 'Unknown')} · ${escapeHtml(visitor.platform || 'Unknown')}</div>
|
||||
</td>
|
||||
<td class="px-3 py-4">
|
||||
<a href="${escapeHtml(currentUrl)}" class="break-all text-cyan-200 hover:text-cyan-100 hover:underline">${escapeHtml(currentUrl)}</a>
|
||||
<div class="mt-1 text-xs text-zinc-500">${escapeHtml(visitor.route_name || 'No route name')}</div>
|
||||
</td>
|
||||
<td class="px-3 py-4 break-all text-zinc-400">${escapeHtml(referer)}</td>
|
||||
<td class="px-3 py-4 text-zinc-300">${escapeHtml(formatDate(visitor.first_seen_at))}</td>
|
||||
<td class="px-3 py-4 text-zinc-300">${escapeHtml(formatDate(visitor.last_seen_at))}</td>
|
||||
<td class="px-3 py-4 text-white">${escapeHtml(visitor.hits || 0)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderActivePages() {
|
||||
const pages = state.active_pages || [];
|
||||
|
||||
if (pages.length === 0) {
|
||||
activePagesContainer.innerHTML = '<div class="rounded-2xl border border-dashed border-white/10 px-4 py-6 text-sm text-zinc-500">No active public pages recorded.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
activePagesContainer.innerHTML = pages.map((page) => `
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<a href="${escapeHtml(page.url)}" class="break-all text-sm font-medium text-cyan-200 hover:text-cyan-100 hover:underline">${escapeHtml(page.url)}</a>
|
||||
<span class="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-1 text-xs text-zinc-300">${escapeHtml(page.visitors)} online</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderGeneratedAt() {
|
||||
generatedAtContainer.textContent = `Updated ${formatDate(state.generated_at)}`;
|
||||
}
|
||||
|
||||
async function loadOnlineVisitors() {
|
||||
try {
|
||||
const response = await fetch(dataUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = await response.json();
|
||||
renderAll();
|
||||
} catch (_error) {
|
||||
// Keep the last rendered snapshot if polling fails.
|
||||
}
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderSummary();
|
||||
renderFilters();
|
||||
renderVisitors();
|
||||
renderActivePages();
|
||||
renderGeneratedAt();
|
||||
}
|
||||
|
||||
renderAll();
|
||||
setInterval(loadOnlineVisitors, 10000);
|
||||
})();
|
||||
</script>
|
||||
@endsection
|
||||
Reference in New Issue
Block a user