refactor: S06.1 reorganize frontend routes into subdirectories (media/tools/admin)
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
<script>
|
||||
import { get } from "../../lib/api.js";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let logs = $state([]);
|
||||
let stats = $state(null);
|
||||
let loading = $state(false);
|
||||
let filter = $state("all");
|
||||
let ipFilter = $state("");
|
||||
let startDate = $state("");
|
||||
let endDate = $state("");
|
||||
let filterError = $state("");
|
||||
|
||||
onMount(loadAll);
|
||||
|
||||
async function loadAll() {
|
||||
loading = true;
|
||||
await Promise.all([loadStats(), loadLogs()]);
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats = await get("/security/stats");
|
||||
} catch (e) {
|
||||
console.error("Failed to load security stats:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLogsQuery() {
|
||||
const params = new URLSearchParams({ limit: "300" });
|
||||
if (filter !== "all") params.set("type", filter);
|
||||
if (ipFilter.trim()) params.set("ip", ipFilter.trim());
|
||||
if (startDate) params.set("start_date", startDate);
|
||||
if (endDate) params.set("end_date", endDate);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function validateFilters() {
|
||||
if (startDate && endDate && startDate > endDate) {
|
||||
filterError = "From date must be on or before To date";
|
||||
return false;
|
||||
}
|
||||
filterError = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
if (!validateFilters()) {
|
||||
logs = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await get(`/security/logs?${buildLogsQuery()}`);
|
||||
logs = res.logs;
|
||||
} catch (e) {
|
||||
console.error("Failed to load security logs:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTypeFilter(nextFilter) {
|
||||
filter = nextFilter;
|
||||
await loadLogs();
|
||||
}
|
||||
|
||||
async function applyIpFilter(ip) {
|
||||
ipFilter = ip;
|
||||
await loadLogs();
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
filter = "all";
|
||||
ipFilter = "";
|
||||
startDate = "";
|
||||
endDate = "";
|
||||
filterError = "";
|
||||
await loadLogs();
|
||||
}
|
||||
|
||||
let maxTimelineCount = $derived(
|
||||
stats ? Math.max(1, ...stats.timeline.map(t => t.count)) : 1
|
||||
);
|
||||
|
||||
let hasActiveFilters = $derived(
|
||||
filter !== "all" || !!ipFilter.trim() || !!startDate || !!endDate
|
||||
);
|
||||
|
||||
function isBlockedScannerEvent(entry) {
|
||||
if (entry.type !== "unauthorized") return false;
|
||||
|
||||
const message = (entry.message || "").toLowerCase();
|
||||
return [
|
||||
"/.git/",
|
||||
"phpinfo",
|
||||
"debug.log",
|
||||
"laravel.log",
|
||||
"stripe",
|
||||
"swagger.json",
|
||||
"wp-config",
|
||||
"credentials",
|
||||
"secrets",
|
||||
"config",
|
||||
"@vite",
|
||||
"manifest.json",
|
||||
].some((needle) => message.includes(needle));
|
||||
}
|
||||
|
||||
const logTypeFilters = ["all", "auth_failure", "suspicious_request", "unauthorized"];
|
||||
const logTypeButtonLabels = {
|
||||
all: "All",
|
||||
auth_failure: "Auth",
|
||||
suspicious_request: "Requests",
|
||||
unauthorized: "Blocked",
|
||||
};
|
||||
|
||||
const typeLabels = { auth_failure: "Auth", suspicious_request: "Request", unauthorized: "Blocked", error: "Error" };
|
||||
const typeColors = {
|
||||
auth_failure: "text-red-400",
|
||||
suspicious_request: "text-amber-400",
|
||||
unauthorized: "text-orange-400",
|
||||
error: "text-surface-500"
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-surface-800 dark:text-surface-100">Security</h2>
|
||||
<button onclick={loadAll} disabled={loading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats cards -->
|
||||
{#if stats}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Failed Logins (24h)</p>
|
||||
<p class="text-2xl font-bold text-red-600 dark:text-red-400 mt-1">{stats.failed_logins_24h}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Suspicious Requests</p>
|
||||
<p class="text-2xl font-bold text-amber-600 dark:text-amber-400 mt-1">{stats.suspicious_requests}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Unique IPs</p>
|
||||
<p class="text-2xl font-bold text-surface-700 dark:text-surface-200 mt-1">{stats.unique_ips}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Timeline chart -->
|
||||
{#if stats?.timeline}
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Activity (Last 24h)</h3>
|
||||
<div class="flex items-end gap-1 h-24">
|
||||
{#each [...stats.timeline].reverse() as bar}
|
||||
<div class="flex-1 flex flex-col items-center justify-end h-full">
|
||||
<div
|
||||
class="w-full rounded-t transition-all {bar.count > 0 ? 'bg-primary-500 dark:bg-primary-400' : 'bg-surface-100 dark:bg-surface-700'}"
|
||||
style="height: {bar.count > 0 ? Math.max(8, (bar.count / maxTimelineCount) * 100) : 4}%"
|
||||
title="{bar.hours_ago}h ago: {bar.count} events"
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex justify-between mt-1">
|
||||
<span class="text-[10px] text-surface-400">24h ago</span>
|
||||
<span class="text-[10px] text-surface-400">now</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Top IPs -->
|
||||
{#if stats?.top_ips?.length}
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top IPs</h3>
|
||||
<div class="space-y-2">
|
||||
{#each stats.top_ips as entry}
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<button onclick={() => applyIpFilter(entry.ip)} class="text-xs font-mono text-surface-600 hover:text-primary-600 dark:text-surface-300 dark:hover:text-primary-300 w-36 shrink-0 text-left transition-colors">{entry.ip}</button>
|
||||
<div class="flex-1 bg-surface-100 dark:bg-surface-700 rounded-full h-2 overflow-hidden">
|
||||
<div class="bg-red-500 dark:bg-red-400 h-full rounded-full" style="width: {(entry.count / stats.top_ips[0].count) * 100}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-surface-500 w-8 text-right">{entry.count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Log table -->
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Event Log</h3>
|
||||
<p class="text-xs text-surface-400 mt-1">Filter recent events by type, IP, or date range.</p>
|
||||
</div>
|
||||
{#if hasActiveFilters}
|
||||
<button onclick={clearFilters} class="px-2.5 py-1 text-xs rounded-md transition-colors bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600">
|
||||
Clear filters
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
{#each logTypeFilters as f}
|
||||
<button onclick={() => applyTypeFilter(f)} class="px-2.5 py-1 text-xs rounded-md transition-colors {filter === f ? 'bg-surface-800 text-white dark:bg-surface-200 dark:text-surface-900' : 'bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
|
||||
{logTypeButtonLabels[f]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-surface-500 mb-1">Filter by IP</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={ipFilter}
|
||||
placeholder="e.g. 1.2.3.4"
|
||||
onkeydown={async (e) => { if (e.key === 'Enter') await loadLogs(); }}
|
||||
class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-surface-500 mb-1">From</span>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={startDate}
|
||||
onchange={loadLogs}
|
||||
class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-surface-500 mb-1">To</span>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={endDate}
|
||||
onchange={loadLogs}
|
||||
class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<button onclick={loadLogs} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors dark:bg-primary-900/30 dark:text-primary-300">
|
||||
Apply filters
|
||||
</button>
|
||||
{#if ipFilter.trim()}
|
||||
<button onclick={() => applyIpFilter(ipFilter.trim())} class="px-2.5 py-1 text-xs rounded-md transition-colors bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600">
|
||||
Use typed IP
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if filterError}
|
||||
<p class="text-sm text-rose-500 mb-3">{filterError}</p>
|
||||
{/if}
|
||||
|
||||
{#if logs.length > 0}
|
||||
<div class="max-h-96 overflow-auto rounded-lg bg-surface-950 p-3 space-y-0.5">
|
||||
{#each logs as e}
|
||||
<div class="text-xs font-mono flex gap-2 leading-5">
|
||||
<span class="{typeColors[e.type] || 'text-surface-500'} w-12 shrink-0">{typeLabels[e.type] || e.type}</span>
|
||||
<span class="text-surface-500 shrink-0">{e.timestamp}</span>
|
||||
{#if e.ip}
|
||||
<button onclick={() => applyIpFilter(e.ip)} class="text-surface-400 hover:text-primary-300 shrink-0 w-28 text-left transition-colors">{e.ip}</button>
|
||||
{/if}
|
||||
{#if e.username}
|
||||
<span class="text-red-400 shrink-0">{e.username}</span>
|
||||
{/if}
|
||||
{#if isBlockedScannerEvent(e)}
|
||||
<span class="shrink-0 rounded-md bg-orange-500/15 px-1.5 py-0.5 text-[10px] font-medium text-orange-300 border border-orange-500/30">scanner</span>
|
||||
{/if}
|
||||
<span class="text-surface-300 truncate">{e.message}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !loading}
|
||||
<p class="text-sm text-surface-400">{hasActiveFilters ? "No security events match the current filters" : "No security events found"}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user