Files
notify-bridge/frontend/src/routes/+page.svelte
T
alexei.dolgolyov 734e5c9340 feat: UX improvements — secure webhooks, locale fixes, dynamic languages, UI polish
- 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
2026-04-11 02:14:15 +03:00

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>