734e5c9340
- Remove top paginator from dashboard events, keep only bottom - Fix test message locale: pass UI locale to email/matrix bot tests - Convert webhook auth mode from text input to icon grid selector - Generate secure UUID tokens for webhook URLs instead of sequential IDs - Move Recent Payloads into per-provider expandable container (lazy-loaded) - Make template config languages dynamic via app settings instead of hardcoded - Change default dev port to 5175
389 lines
17 KiB
Svelte
389 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { slide } from 'svelte/transition';
|
|
import { api } from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import { providersCache } from '$lib/stores/caches.svelte';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import Loading from '$lib/components/Loading.svelte';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import EventChart from '$lib/components/EventChart.svelte';
|
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
|
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
|
import { getDescriptor } from '$lib/providers';
|
|
|
|
import type { DashboardStatus } from '$lib/types';
|
|
|
|
const CHART_KEY = 'dashboard_chart_visible';
|
|
let chartVisible = $state(typeof localStorage !== 'undefined' ? localStorage.getItem(CHART_KEY) !== 'false' : true);
|
|
function toggleChart() {
|
|
chartVisible = !chartVisible;
|
|
if (typeof localStorage !== 'undefined') localStorage.setItem(CHART_KEY, String(chartVisible));
|
|
}
|
|
|
|
let status = $state<DashboardStatus | null>(null);
|
|
let providers = $derived(providersCache.items);
|
|
const providerFilterItems = $derived([
|
|
{ value: '', label: t('dashboard.allProviders'), icon: 'mdiFilterOff' },
|
|
...providers.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })),
|
|
]);
|
|
let chartDays = $state<{ date: string; [eventType: string]: string | number }[]>([]);
|
|
let loaded = $state(false);
|
|
let error = $state('');
|
|
|
|
let displayProviders = $state(0);
|
|
let displayActive = $state(0);
|
|
let displayTotal = $state(0);
|
|
let displayTargets = $state(0);
|
|
|
|
// Event filters
|
|
let filterEventType = $state('');
|
|
let filterProviderId = $state('');
|
|
let effectiveProviderId = $derived(globalProviderFilter.id ? String(globalProviderFilter.id) : filterProviderId);
|
|
let filterSearch = $state('');
|
|
let filterSort = $state('newest');
|
|
|
|
const EVENTS_PER_PAGE_KEY = 'dashboard_events_per_page';
|
|
function loadEventsPerPage(): number {
|
|
if (typeof localStorage === 'undefined') return 10;
|
|
const stored = localStorage.getItem(EVENTS_PER_PAGE_KEY);
|
|
return stored ? parseInt(stored, 10) || 10 : 10;
|
|
}
|
|
|
|
let eventsLimit = $state(loadEventsPerPage());
|
|
let eventsOffset = $state(0);
|
|
let eventsLoading = $state(false);
|
|
|
|
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
|
|
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
|
|
|
|
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
|
|
if (to === 0) { setter(0); return; }
|
|
const start = performance.now();
|
|
function frame(now: number) {
|
|
const elapsed = now - start;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const eased = 1 - Math.pow(1 - progress, 3);
|
|
setter(Math.round(from + (to - from) * eased));
|
|
if (progress < 1) requestAnimationFrame(frame);
|
|
}
|
|
requestAnimationFrame(frame);
|
|
}
|
|
|
|
/** Build filter query string (shared by events list + chart). */
|
|
function buildFilterParams(): URLSearchParams {
|
|
const params = new URLSearchParams();
|
|
if (filterEventType) params.set('event_type', filterEventType);
|
|
if (effectiveProviderId) params.set('provider_id', effectiveProviderId);
|
|
if (filterSearch) params.set('search', filterSearch);
|
|
return params;
|
|
}
|
|
|
|
async function loadEvents() {
|
|
eventsLoading = true;
|
|
try {
|
|
const params = buildFilterParams();
|
|
params.set('sort', filterSort);
|
|
params.set('limit', String(eventsLimit));
|
|
params.set('offset', String(eventsOffset));
|
|
const qs = params.toString();
|
|
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
|
|
} catch (err: any) {
|
|
error = err.message || t('common.error');
|
|
} finally {
|
|
eventsLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadChart() {
|
|
try {
|
|
const params = buildFilterParams();
|
|
const qs = params.toString();
|
|
const chartRes = await api<{ days: { date: string; [k: string]: string | number }[] }>(`/status/chart${qs ? '?' + qs : ''}`);
|
|
chartDays = chartRes.days || [];
|
|
} catch (e) { console.warn('Failed to load chart data:', e); }
|
|
}
|
|
|
|
// Auto-apply when filter values change (via IconGridSelect bind:value)
|
|
let _prevFilterKey = '';
|
|
$effect(() => {
|
|
const key = `${filterEventType}|${effectiveProviderId}|${filterSort}`;
|
|
if (loaded && key !== _prevFilterKey && _prevFilterKey !== '') {
|
|
applyFilters();
|
|
}
|
|
_prevFilterKey = key;
|
|
});
|
|
|
|
function applyFilters() {
|
|
eventsOffset = 0;
|
|
loadEvents();
|
|
loadChart();
|
|
}
|
|
|
|
function goToPage(page: number) {
|
|
eventsOffset = (page - 1) * eventsLimit;
|
|
loadEvents();
|
|
}
|
|
|
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
|
function onSearchInput() {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(applyFilters, 300);
|
|
}
|
|
|
|
onMount(() => {
|
|
loadInitial();
|
|
return () => {
|
|
clearTimeout(searchTimeout);
|
|
};
|
|
});
|
|
|
|
async function loadInitial() {
|
|
try {
|
|
const [statusRes, , chartRes] = await Promise.all([
|
|
api<DashboardStatus>(`/status?limit=${eventsLimit}`),
|
|
providersCache.fetch(),
|
|
api<{ days: { date: string; [k: string]: string | number }[] }>('/status/chart'),
|
|
]);
|
|
status = statusRes;
|
|
chartDays = chartRes.days || [];
|
|
setTimeout(() => {
|
|
if (!status) return;
|
|
animateCount(0, status.providers, (v) => displayProviders = v);
|
|
animateCount(0, status.trackers.active, (v) => displayActive = v);
|
|
animateCount(0, status.trackers.total, (v) => displayTotal = v);
|
|
animateCount(0, status.targets, (v) => displayTargets = v);
|
|
if (status.command_trackers !== undefined) {
|
|
animateCount(0, status.command_trackers, (v) => displayCommandTrackers = v);
|
|
}
|
|
}, 200);
|
|
} catch (err: any) {
|
|
error = err.message || t('common.error');
|
|
} finally {
|
|
loaded = true;
|
|
}
|
|
}
|
|
|
|
let displayCommandTrackers = $state(0);
|
|
|
|
const filteredProviderCount = $derived(globalProviderFilter.providerType
|
|
? providers.filter(p => p.type === globalProviderFilter.providerType).length
|
|
: displayProviders);
|
|
|
|
const providerCard = $derived.by(() => {
|
|
const gp = globalProviderFilter.provider;
|
|
if (gp) {
|
|
const desc = getDescriptor(gp.type);
|
|
return { icon: providerDefaultIcon(gp), label: '', literalLabel: desc?.defaultName ?? gp.type, value: 0, literalValue: gp.name, color: '#0d9488' };
|
|
}
|
|
return { icon: 'mdiServer', label: 'dashboard.providers', value: filteredProviderCount, color: '#0d9488' };
|
|
});
|
|
|
|
interface StatCard { icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; color: string }
|
|
const statCards = $derived<StatCard[]>(status ? [
|
|
providerCard,
|
|
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
|
|
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
|
|
...(status.command_trackers !== undefined ? [{ icon: 'mdiConsoleLine', label: 'nav.commandTrackers', value: displayCommandTrackers, color: '#8b5cf6' }] : []),
|
|
] : []);
|
|
|
|
function timeAgo(dateStr: string): string {
|
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return t('dashboard.justNow');
|
|
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return t('dashboard.hoursAgo').replace('{n}', String(hours));
|
|
return t('dashboard.daysAgo').replace('{n}', String(Math.floor(hours / 24)));
|
|
}
|
|
|
|
const eventLabels: Record<string, string> = {
|
|
assets_added: 'dashboard.assetsAdded',
|
|
assets_removed: 'dashboard.assetsRemoved',
|
|
collection_renamed: 'dashboard.collectionRenamed',
|
|
collection_deleted: 'dashboard.collectionDeleted',
|
|
sharing_changed: 'dashboard.sharingChanged',
|
|
};
|
|
|
|
const eventIcons: Record<string, string> = {
|
|
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
|
|
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
|
};
|
|
const eventColors: Record<string, string> = {
|
|
assets_added: '#059669', assets_removed: '#ef4444',
|
|
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
|
};
|
|
|
|
</script>
|
|
|
|
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
|
|
|
{#if !loaded}
|
|
<Loading lines={4} />
|
|
{:else if error}
|
|
<Card>
|
|
<div class="flex items-center gap-2" style="color: var(--color-error-fg);">
|
|
<MdiIcon name="mdiAlertCircle" size={20} />
|
|
<p class="text-sm">{error}</p>
|
|
</div>
|
|
</Card>
|
|
{:else if status}
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8 stagger-children">
|
|
{#each statCards as card, i}
|
|
<div class="stat-card" style="--accent: {card.color};">
|
|
<div class="stat-card-inner">
|
|
<div class="flex items-center gap-3">
|
|
<div class="stat-icon" style="background: {card.color}15; color: {card.color};">
|
|
<MdiIcon name={card.icon} size={22} />
|
|
</div>
|
|
<div>
|
|
<p class="text-sm" style="color: var(--color-muted-foreground);">{card.literalLabel || t(card.label)}</p>
|
|
<p class="{card.literalValue ? 'stat-value-text' : 'stat-value'} font-mono" style="animation-delay: {i * 80 + 200}ms;">
|
|
{#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Events section -->
|
|
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
|
<MdiIcon name="mdiPulse" size={18} />
|
|
{t('dashboard.recentEvents')}
|
|
{#if status.total_events > 0}
|
|
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
|
|
{/if}
|
|
</h3>
|
|
|
|
<!-- Filters -->
|
|
<div class="flex flex-wrap items-center gap-2 mb-4">
|
|
<div class="flex-1 min-w-[150px] max-w-[260px]">
|
|
<input type="text" bind:value={filterSearch} oninput={onSearchInput}
|
|
placeholder={t('dashboard.searchEvents')}
|
|
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
|
|
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
|
|
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
|
|
</div>
|
|
|
|
<!-- Chart -->
|
|
<button type="button" onclick={toggleChart}
|
|
class="flex items-center gap-1.5 text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors mb-2 cursor-pointer">
|
|
<MdiIcon name={chartVisible ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
|
|
<MdiIcon name="mdiChartBar" size={14} />
|
|
{t('dashboard.chart')}
|
|
</button>
|
|
{#if chartVisible}
|
|
<div in:slide={{ duration: 200 }}>
|
|
<EventChart days={chartDays} />
|
|
</div>
|
|
{/if}
|
|
|
|
{#snippet paginator()}
|
|
<div class="flex items-center justify-center gap-1">
|
|
{#if totalPages > 1}
|
|
<button onclick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1}
|
|
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 disabled:cursor-default">
|
|
<MdiIcon name="mdiChevronLeft" size={16} />
|
|
</button>
|
|
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
|
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
|
|
<button onclick={() => goToPage(page)}
|
|
class="px-2.5 py-1 text-xs font-mono rounded-md border transition-colors {page === currentPage
|
|
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)] border-[var(--color-primary)]'
|
|
: 'border-[var(--color-border)] hover:bg-[var(--color-muted)]'}">
|
|
{page}
|
|
</button>
|
|
{:else if page === currentPage - 2 || page === currentPage + 2}
|
|
<span class="px-1 text-xs text-[var(--color-muted-foreground)]">...</span>
|
|
{/if}
|
|
{/each}
|
|
<button onclick={() => goToPage(currentPage + 1)} disabled={currentPage >= totalPages}
|
|
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 disabled:cursor-default">
|
|
<MdiIcon name="mdiChevronRight" size={16} />
|
|
</button>
|
|
{/if}
|
|
<select value={eventsLimit}
|
|
onchange={(e) => { const v = parseInt((e.target as HTMLSelectElement).value, 10); eventsLimit = v; eventsOffset = 0; if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_PER_PAGE_KEY, String(v)); loadEvents(); }}
|
|
class="ml-2 px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)] text-[var(--color-foreground)]">
|
|
{#each [5, 10, 20, 50] as n}
|
|
<option value={n}>{n}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#if eventsLoading}
|
|
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
|
|
{:else if status.recent_events.length === 0}
|
|
<Card>
|
|
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
|
<div style="opacity: 0.4;"><MdiIcon name="mdiCalendarBlank" size={40} /></div>
|
|
<p class="text-sm">{t('dashboard.noEvents')}</p>
|
|
</div>
|
|
</Card>
|
|
{:else}
|
|
<div class="event-timeline stagger-children">
|
|
{#each status.recent_events as event, i}
|
|
<div class="event-item" style="animation-delay: {i * 60}ms;">
|
|
<div class="event-dot" style="background: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; box-shadow: 0 0 8px {eventColors[event.event_type] || 'var(--color-muted-foreground)'}40;"></div>
|
|
{#if i < status.recent_events.length - 1}<div class="event-line"></div>{/if}
|
|
<div class="event-content">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<div class="flex items-center gap-2 min-w-0 flex-wrap">
|
|
<span style="color: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; flex-shrink: 0;">
|
|
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
|
|
</span>
|
|
<span class="text-sm font-medium truncate">{event.collection_name}</span>
|
|
<span class="event-badge">{t(eventLabels[event.event_type] || event.event_type)}</span>
|
|
{#if event.assets_count > 0}
|
|
<span class="event-badge" style="background: {eventColors[event.event_type]}20; color: {eventColors[event.event_type]};">{event.assets_count} {event.assets_count === 1 ? t('dashboard.asset') : t('dashboard.assets')}</span>
|
|
{/if}
|
|
</div>
|
|
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
|
|
</div>
|
|
{#if event.provider_name || event.tracker_name}
|
|
<div class="flex items-center gap-2 mt-1 text-xs" style="color: var(--color-muted-foreground);">
|
|
{#if event.provider_name}
|
|
<span class="flex items-center gap-1"><MdiIcon name="mdiServer" size={12} />{event.provider_name}</span>
|
|
{/if}
|
|
{#if event.tracker_name}
|
|
<span class="flex items-center gap-1"><MdiIcon name="mdiRadar" size={12} />{event.tracker_name}</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Bottom paginator -->
|
|
<div class="mt-4">
|
|
{@render paginator()}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<style>
|
|
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
|
|
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
|
|
.stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; }
|
|
.stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; }
|
|
.stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; animation: countUp 0.5s ease-out both; }
|
|
.stat-value-text { font-size: 1rem; font-weight: 600; line-height: 1.3; animation: countUp 0.5s ease-out both; }
|
|
.stat-suffix { font-size: 1rem; font-weight: 400; color: var(--color-muted-foreground); }
|
|
.event-timeline { display: flex; flex-direction: column; }
|
|
.event-item { display: flex; align-items: flex-start; gap: 0.75rem; position: relative; padding-bottom: 0.5rem; }
|
|
.event-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; z-index: 1; }
|
|
.event-line { position: absolute; left: 4px; top: 18px; bottom: 0; width: 2px; background: var(--color-border); }
|
|
.event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
|
|
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
|
|
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
|
|
</style>
|