feat: watchtower tracking, dev environment, security logs page
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m54s

- watchtower: docker-compose with label filtering, email notifications
- watchtower labels on navidrome, openclaw, immich services
- dev env: docker-compose.dev.yml (port 4001), deploy-dev.yml CI workflow
- dev.nas.jimmygan.com Caddy site block with Authelia forward_auth
- security page: backend router parsing Authelia/Caddy container logs
- security page: frontend with stats cards, timeline, top IPs, event log
- wired security route into App.svelte and Sidebar
This commit is contained in:
Gan, Jimmy
2026-03-01 00:40:14 +08:00
parent 44aafe7ea5
commit 4b32929dcc
13 changed files with 475 additions and 1 deletions
@@ -0,0 +1,148 @@
<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");
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);
}
}
async function loadLogs() {
try {
const res = await get("/security/logs?limit=300");
logs = res.logs;
} catch (e) {
console.error("Failed to load security logs:", e);
}
}
let filteredLogs = $derived(
filter === "all" ? logs : logs.filter(l => l.type === filter)
);
let maxTimelineCount = $derived(
stats ? Math.max(1, ...stats.timeline.map(t => t.count)) : 1
);
const typeLabels = { auth_failure: "Auth", suspicious_request: "Request", error: "Error" };
const typeColors = { auth_failure: "text-red-400", suspicious_request: "text-amber-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">
<code class="text-xs font-mono text-surface-600 dark:text-surface-300 w-36 shrink-0">{entry.ip}</code>
<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-center justify-between mb-3">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Event Log</h3>
<div class="flex gap-1.5">
{#each ["all", "auth_failure", "suspicious_request"] as f}
<button onclick={() => filter = 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:hover:bg-surface-600'}">
{f === "all" ? "All" : f === "auth_failure" ? "Auth" : "Requests"}
{#if f !== "all"}
<span class="ml-1 opacity-60">{logs.filter(l => l.type === f).length}</span>
{/if}
</button>
{/each}
</div>
</div>
{#if filteredLogs.length > 0}
<div class="max-h-96 overflow-auto rounded-lg bg-surface-950 p-3 space-y-0.5">
{#each filteredLogs 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}
<span class="text-surface-400 shrink-0 w-28">{e.ip}</span>
{/if}
{#if e.username}
<span class="text-red-400 shrink-0">{e.username}</span>
{/if}
<span class="text-surface-300 truncate">{e.message}</span>
</div>
{/each}
</div>
{:else if !loading}
<p class="text-sm text-surface-400">No security events found</p>
{/if}
</div>
</div>