diff --git a/frontend/src/lib/components/NavIcon.svelte b/frontend/src/lib/components/NavIcon.svelte new file mode 100644 index 0000000..a485d2f --- /dev/null +++ b/frontend/src/lib/components/NavIcon.svelte @@ -0,0 +1,92 @@ + + +{#if name === 'mdiViewDashboard'} + +{:else if name === 'mdiServer'} + +{:else if name === 'mdiBellOutline' || name === 'mdiBell'} + +{:else if name === 'mdiConsoleLine'} + +{:else if name === 'mdiRobotOutline' || name === 'mdiRobot'} + +{:else if name === 'mdiTarget'} + +{:else if name === 'mdiCogOutline' || name === 'mdiCog'} + +{:else if name === 'mdiRadar'} + +{:else if name === 'mdiFileDocumentEdit'} + +{:else if name === 'mdiCodeBracesBox'} + +{:else if name === 'mdiPlayCircleOutline'} + +{:else if name === 'mdiSendCircle' || name === 'mdiSend'} + +{:else if name === 'mdiEmailOutline'} + +{:else if name === 'mdiMatrix'} + +{:else if name === 'mdiWebhook'} + +{:else if name === 'mdiChat'} + +{:else if name === 'mdiSlack'} + +{:else if name === 'mdiBullhorn'} + +{:else if name === 'mdiBackupRestore'} + +{:else if name === 'mdiAccountGroup'} + +{:else if name === 'mdiChevronRight'} + +{:else if name === 'mdiChevronLeft'} + +{:else if name === 'mdiChevronDown'} + +{:else if name === 'mdiMagnify'} + +{:else if name === 'mdiLogout'} + +{:else if name === 'mdiKeyVariant'} + +{:else if name === 'mdiApi'} + +{:else if name === 'mdiWeatherNight'} + +{:else if name === 'mdiWeatherSunny'} + +{:else if name === 'mdiDesktopTowerMonitor'} + +{:else if name === 'mdiFilterOff'} + +{:else if name === 'mdiDotsHorizontal'} + +{:else if name === 'mdiPulse'} + +{:else if name === 'mdiPlus'} + +{:else if name === 'mdiArrowRight'} + +{:else} + +{/if} diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 346722d..9288386 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -103,7 +103,39 @@ "last14days": "Last 14 days", "event": "event", "events": "events", - "noChartData": "No event data yet" + "noChartData": "No event data yet", + "live": "Live", + "attention": "Attention", + "heroPrefix": "Tonight,", + "heroEmphasis": "everything", + "heroSuffix": "is flowing.", + "heroSummary": "{providers} providers listening, {armed} of {total} trackers armed, {throughput} events dispatched across {targets} targets in 24h.", + "throughput24h": "throughput · 24h", + "eventsShort": "events", + "armedShort": "armed", + "providersShort": "providers", + "targetsShort": "targets", + "trackersShort": "trackers", + "streamTitle": "Signal", + "streamEmphasis": "stream", + "eventsLabel": "events", + "onWatchTitle": "On", + "onWatchEmphasis": "watch", + "noProviders": "No providers yet.", + "addProvider": "Add provider", + "addProviderHint": "Connect a service to start tracking", + "pulseTitle": "Pulse", + "pulseEmphasis": "· last 14 days", + "pulseSub": "Events grouped by day", + "wiresTitle": "Active", + "wiresEmphasis": "wires", + "wiresSub": "routes", + "composeTitle": "Pick a source. Choose a channel.", + "composeEmphasis": "Compose the wire.", + "composeSub": "Walk from provider → tracker → template → target. Or paste a webhook URL and we'll infer the rest.", + "viewTrackers": "View trackers", + "newTracker": "New tracker", + "eventsTotal": "Events" }, "providers": { "title": "Providers", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index b5b8b67..bbaca58 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -103,7 +103,39 @@ "last14days": "Последние 14 дней", "event": "событие", "events": "событий", - "noChartData": "Нет данных о событиях" + "noChartData": "Нет данных о событиях", + "live": "В эфире", + "attention": "Внимание", + "heroPrefix": "Сегодня", + "heroEmphasis": "всё", + "heroSuffix": "идёт по плану.", + "heroSummary": "{providers} провайдеров на связи, {armed} из {total} трекеров активны, {throughput} событий доставлено в {targets} каналов за сутки.", + "throughput24h": "пропускная способность · 24ч", + "eventsShort": "событий", + "armedShort": "активны", + "providersShort": "провайдеров", + "targetsShort": "каналов", + "trackersShort": "трекеров", + "streamTitle": "Поток", + "streamEmphasis": "сигналов", + "eventsLabel": "событий", + "onWatchTitle": "На", + "onWatchEmphasis": "слежении", + "noProviders": "Пока нет провайдеров.", + "addProvider": "Добавить", + "addProviderHint": "Подключите сервис, чтобы начать слежение", + "pulseTitle": "Пульс", + "pulseEmphasis": "· 14 дней", + "pulseSub": "События по дням", + "wiresTitle": "Активные", + "wiresEmphasis": "линии", + "wiresSub": "маршрутов", + "composeTitle": "Выберите источник, выберите канал.", + "composeEmphasis": "Свяжите.", + "composeSub": "Проведите путь от провайдера → трекер → шаблон → цель. Или вставьте webhook URL — остальное мы определим сами.", + "viewTrackers": "К трекерам", + "newTracker": "Новый трекер", + "eventsTotal": "Событий" }, "providers": { "title": "Провайдеры", diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 61b2e06..c904781 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -10,7 +10,7 @@ import { t, getLocale, setLocale } from '$lib/i18n'; import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte'; import Modal from '$lib/components/Modal.svelte'; - import MdiIcon from '$lib/components/MdiIcon.svelte'; + import NavIcon from '$lib/components/NavIcon.svelte'; import Snackbar from '$lib/components/Snackbar.svelte'; import SearchPalette from '$lib/components/SearchPalette.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; @@ -359,7 +359,7 @@

{#if globalProviderFilter.provider} - + {/if} Notify Bridge

@@ -372,7 +372,7 @@
@@ -387,7 +387,7 @@ }} class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200" title={globalProviderFilter.provider?.name || t('common.allProviders')}> - + {:else} @@ -400,7 +400,7 @@ @@ -440,7 +440,7 @@ {#if isActive(child.href)}
{/if} - + {t(child.key)} {#if child.countKey && navCounts[child.countKey]} {navCounts[child.countKey]} @@ -459,7 +459,7 @@ {#if isActive(entry.href)}
{/if} - + {#if !collapsed} {t(entry.key)} {#if entry.countKey && navCounts[entry.countKey]} @@ -483,12 +483,12 @@ - + @@ -498,7 +498,7 @@ {:else}
@@ -515,12 +515,12 @@
@@ -535,18 +535,18 @@ - + {/each} @@ -567,7 +567,7 @@
- + {t(entry.key)}
@@ -576,7 +576,7 @@ class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200 relative" style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'};" > - + {t(child.key)} {#if child.countKey && navCounts[child.countKey]} {navCounts[child.countKey]} @@ -590,7 +590,7 @@ class="flex items-center gap-2 p-3 rounded-lg transition-all duration-200 relative" style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'};" > - + {t(entry.key)} {#if entry.countKey && navCounts[entry.countKey]} {navCounts[entry.countKey]} @@ -602,7 +602,7 @@
@@ -614,7 +614,7 @@
{#key page.url.pathname} -
+
{@render children()}
{/key} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 389cd8e..f3e5ede 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -3,11 +3,14 @@ import { slide } from 'svelte/transition'; import { api, parseDate } 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 { + providersCache, + notificationTrackersCache, + targetsCache, + } from '$lib/stores/caches.svelte'; import Loading from '$lib/components/Loading.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte'; + import NavIcon from '$lib/components/NavIcon.svelte'; import EventChart from '$lib/components/EventChart.svelte'; import IconGridSelect from '$lib/components/IconGridSelect.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte'; @@ -39,6 +42,7 @@ let displayActive = $state(0); let displayTotal = $state(0); let displayTargets = $state(0); + let displayCommandTrackers = $state(0); // Event filters let filterEventType = $state(''); @@ -66,8 +70,8 @@ eventsOffset = 0; await loadEvents(); await loadChart(); - } catch (err: any) { - snackError(err.message || t('common.error')); + } catch (err: unknown) { + snackError(err instanceof Error ? err.message : t('common.error')); } finally { confirmClearEvents = false; } @@ -107,8 +111,8 @@ params.set('offset', String(eventsOffset)); const qs = params.toString(); status = await api(`/status${qs ? '?' + qs : ''}`); - } catch (err: any) { - error = err.message || t('common.error'); + } catch (err: unknown) { + error = err instanceof Error ? err.message : t('common.error'); } finally { eventsLoading = false; } @@ -176,35 +180,104 @@ animateCount(0, status.command_trackers, (v) => displayCommandTrackers = v); } }, 200); - } catch (err: any) { - error = err.message || t('common.error'); + } catch (err: unknown) { + error = err instanceof 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' }; + // === Provider deck — derive activity counts from recent events === + const providerEventCounts = $derived.by(() => { + const counts = new Map(); + if (!status) return counts; + for (const ev of status.recent_events) { + const k = ev.provider_name || ''; + if (!k) continue; + counts.set(k, (counts.get(k) || 0) + (ev.assets_count || 1)); } - return { icon: 'mdiServer', label: 'dashboard.providers', value: filteredProviderCount, color: '#0d9488' }; + return counts; + }); + const providerDeck = $derived.by(() => { + const max = Math.max(1, ...Array.from(providerEventCounts.values())); + return providers.map(p => { + const trackers = notificationTrackersCache.items.filter(t => t.provider_id === p.id); + const events = providerEventCounts.get(p.name) || 0; + return { + id: p.id, + name: p.name, + type: p.type, + icon: providerDefaultIcon(p), + trackerCount: trackers.length, + armedCount: trackers.filter(t => (t as { enabled?: boolean }).enabled !== false).length, + events, + share: events / max, + descriptor: getDescriptor(p.type), + }; + }).sort((a, b) => b.events - a.events); }); - interface StatCard { icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; color: string } - const statCards = $derived(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' }] : []), - ] : []); + // === Active wires — derive top tracker → target routes === + const activeWires = $derived.by(() => { + const wires: Array<{ + trackerName: string; + providerName: string; + providerType: string; + providerIcon: string; + targetName: string; + targetType: string; + targetIcon: string; + events: number; + }> = []; + const targetsById = new Map(targetsCache.items.map(tg => [tg.id, tg])); + const trackerEventCount = new Map(); + if (status) { + for (const ev of status.recent_events) { + const k = ev.tracker_name || ''; + if (!k) continue; + trackerEventCount.set(k, (trackerEventCount.get(k) || 0) + (ev.assets_count || 1)); + } + } + for (const tracker of notificationTrackersCache.items) { + const provider = providers.find(p => p.id === tracker.provider_id); + if (!provider) continue; + const links = (tracker as { tracker_targets?: { target_id: number }[] }).tracker_targets || []; + for (const link of links) { + const target = targetsById.get(link.target_id); + if (!target) continue; + wires.push({ + trackerName: tracker.name, + providerName: provider.name, + providerType: provider.type, + providerIcon: providerDefaultIcon(provider), + targetName: target.name, + targetType: target.type, + targetIcon: target.icon || `mdi${target.type[0].toUpperCase() + target.type.slice(1)}`, + events: trackerEventCount.get(tracker.name) || 0, + }); + } + } + return wires.sort((a, b) => b.events - a.events).slice(0, 6); + }); + + // === Hero summary sentence === */ + const heroSummary = $derived.by(() => { + if (!status) return null; + const failing = status.recent_events.filter(e => /fail|error/.test(e.event_type)).length; + return { + armed: status.trackers.active, + total: status.trackers.total, + providers: status.providers, + targets: status.targets, + throughput: status.total_events, + failing, + allOk: failing === 0, + }; + }); function timeAgo(dateStr: string): string { const diff = Date.now() - parseDate(dateStr).getTime(); @@ -216,6 +289,11 @@ return t('dashboard.daysAgo').replace('{n}', String(Math.floor(hours / 24))); } + function timeShort(dateStr: string): string { + const d = parseDate(dateStr); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + } + const eventLabels: Record = { assets_added: 'dashboard.assetsAdded', assets_removed: 'dashboard.assetsRemoved', @@ -234,181 +312,523 @@ scheduled_message: 'mdiCalendarClock', action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle', }; - const eventColors: Record = { - assets_added: '#059669', assets_removed: '#ef4444', - collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b', - scheduled_message: '#8b5cf6', - action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626', + + // Aurora gradient palette per event type — used for the avatar tile + const eventGradients: Record = { + assets_added: ['var(--color-mint)', 'var(--color-sky)'], + assets_removed: ['var(--color-coral)', 'var(--color-orchid)'], + collection_renamed: ['var(--color-primary)', 'var(--color-orchid)'], + collection_deleted: ['var(--color-coral)', 'var(--color-citrus)'], + sharing_changed: ['var(--color-citrus)', 'var(--color-coral)'], + scheduled_message: ['var(--color-sky)', 'var(--color-mint)'], + action_success: ['var(--color-mint)', 'var(--color-primary)'], + action_partial: ['var(--color-citrus)', 'var(--color-orchid)'], + action_failed: ['var(--color-coral)', 'var(--color-orchid)'], }; + const STAT_ACCENTS = [ + 'var(--color-primary)', + 'var(--color-sky)', + 'var(--color-citrus)', + 'var(--color-orchid)', + ]; - - {#if !loaded} {:else if error} - +

{error}

- -{:else if status} -
- {#each statCards as card, i} -
-
-
-
- -
-
-

{card.literalLabel || t(card.label)}

-

- {#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}{card.suffix}{/if} -

-
+
+{:else if status && heroSummary} + + +
+
+
+
+ {t('dashboard.title')} + · + + + {heroSummary.allOk ? t('dashboard.live') : t('dashboard.attention')} + +
+

+ {t('dashboard.heroPrefix')} {t('dashboard.heroEmphasis')}
+ {t('dashboard.heroSuffix')} +

+

+ {t('dashboard.heroSummary') + .replace('{providers}', String(heroSummary.providers)) + .replace('{armed}', String(heroSummary.armed)) + .replace('{total}', String(heroSummary.total)) + .replace('{throughput}', String(heroSummary.throughput)) + .replace('{targets}', String(heroSummary.targets))} +

+
+
+
{t('dashboard.throughput24h')}
+
{heroSummary.throughput.toLocaleString()}{t('dashboard.eventsShort')}
+
+ {heroSummary.armed}/{heroSummary.total} {t('dashboard.armedShort')} + {heroSummary.providers} {t('dashboard.providersShort')} + {heroSummary.targets} {t('dashboard.targetsShort')} +
+
+
+
+ + + {#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string}, idx: number)} +
+
+
+
+ +
+
+

{card.literalLabel || t(card.label)}

+

+ {#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}{card.suffix}{/if} +

- {/each} -
- - -
-

- - {t('dashboard.recentEvents')} - {#if status.total_events > 0} - ({status.total_events}) - {/if} -

- {#if status.total_events > 0} - - {/if} -
- - -
-
- -
-
- {#if !globalProviderFilter.id}
{/if} -
-
- - - - {#if chartVisible} -
- -
- {/if} - - {#snippet paginator()} -
- {#if totalPages > 1} - - {#each Array.from({ length: totalPages }, (_, i) => i + 1) as page} - {#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)} - - {:else if page === currentPage - 2 || page === currentPage + 2} - ... - {/if} - {/each} - - {/if} -
{/snippet} - {#if eventsLoading} -

{t('dashboard.loadingEvents')}

- {:else if status.recent_events.length === 0} - -
-
-

{t('dashboard.noEvents')}

-
-
- {:else} -
- {#each status.recent_events as event, i} -
-
- {#if i < status.recent_events.length - 1}
{/if} -
-
-
- - - - {event.collection_name} - {t(eventLabels[event.event_type] || event.event_type)} - {#if event.assets_count > 0} - {event.assets_count} {event.assets_count === 1 ? t('dashboard.asset') : t('dashboard.assets')} - {/if} -
- {timeAgo(event.created_at)} -
- {#if event.provider_name || event.tracker_name} -
- {#if event.provider_name} - {event.provider_name} - {/if} - {#if event.tracker_name} - {event.tracker_name} - {/if} -
- {/if} -
-
- {/each} + {#snippet statCards()} +
+ {#if globalProviderFilter.provider} + {@render statCardSnippet({ + icon: providerDefaultIcon(globalProviderFilter.provider), + label: '', + literalLabel: getDescriptor(globalProviderFilter.provider.type)?.defaultName ?? globalProviderFilter.provider.type, + value: 0, + literalValue: globalProviderFilter.provider.name, + accent: STAT_ACCENTS[0], + }, 0)} + {:else} + {@render statCardSnippet({ + icon: 'mdiServer', + label: 'dashboard.providers', + value: filteredProviderCount, + accent: STAT_ACCENTS[0], + }, 0)} + {/if} + {@render statCardSnippet({ + icon: 'mdiRadar', + label: 'dashboard.activeTrackers', + value: displayActive, + suffix: ` / ${displayTotal}`, + accent: STAT_ACCENTS[1], + }, 1)} + {@render statCardSnippet({ + icon: 'mdiTarget', + label: 'dashboard.targets', + value: displayTargets, + accent: STAT_ACCENTS[2], + }, 2)} + {#if status?.command_trackers !== undefined} + {@render statCardSnippet({ + icon: 'mdiConsoleLine', + label: 'nav.commandTrackers', + value: displayCommandTrackers, + accent: STAT_ACCENTS[3], + }, 3)} + {:else} + {@render statCardSnippet({ + icon: 'mdiPulse', + label: 'dashboard.eventsTotal', + value: heroSummary?.throughput ?? 0, + accent: STAT_ACCENTS[3], + }, 3)} + {/if}
+ {/snippet} + {@render statCards()} - -
- {@render paginator()} -
+ +
+ +
+
+
+

{t('dashboard.streamTitle')} {t('dashboard.streamEmphasis')}

+

+ {status.total_events} {t('dashboard.eventsLabel')} +

+
+ {#if status.total_events > 0} + + {/if} +
+ +
+
+ +
+
+ {#if !globalProviderFilter.id}
{/if} +
+
+ + {#snippet paginator()} +
+ {#if totalPages > 1} + + {#each Array.from({ length: totalPages }, (_, i) => i + 1) as p} + {#if p === 1 || p === totalPages || (p >= currentPage - 1 && p <= currentPage + 1)} + + {:else if p === currentPage - 2 || p === currentPage + 2} + + {/if} + {/each} + + {/if} + +
+ {/snippet} + + {#if eventsLoading} +

{t('dashboard.loadingEvents')}

+ {:else if status.recent_events.length === 0} +
+ +

{t('dashboard.noEvents')}

+
+ {:else} +
+ {#each status.recent_events as event, i} +
+
+ +
+
+
+ {event.collection_name} + {t(eventLabels[event.event_type] || event.event_type)} + {#if event.assets_count > 0} + {event.assets_count} {event.assets_count === 1 ? t('dashboard.asset') : t('dashboard.assets')} + {/if} +
+ {#if event.provider_name || event.tracker_name} +
+ {#if event.tracker_name} + {event.tracker_name} + + {/if} + {#if event.provider_name} + {event.provider_name} + {/if} +
+ {/if} +
+
+ {timeShort(event.created_at)} + {timeAgo(event.created_at)} +
+
+ {/each} +
+ +
+ {@render paginator()} +
+ {/if} +
+ + +
+
+
+

{t('dashboard.onWatchTitle')} {t('dashboard.onWatchEmphasis')}

+

{providerDeck.length} {t('dashboard.providersShort')}

+
+
+ + {#if providerDeck.length === 0} +
+ +

{t('dashboard.noProviders')}

+ {t('dashboard.addProvider')} → +
+ {:else} + + {/if} +
+
+ + +
+
+
+

{t('dashboard.pulseTitle')} {t('dashboard.pulseEmphasis')}

+

{t('dashboard.pulseSub')}

+
+ +
+ {#if chartVisible} +
+ +
+ {/if} +
+ + + {#if activeWires.length > 0} +
+
+
+

{t('dashboard.wiresTitle')} {t('dashboard.wiresEmphasis')}

+

{activeWires.length} {t('dashboard.wiresSub')}

+
+
+
+ {#each activeWires as wire} +
+
+ +
+
{wire.trackerName}
+
{wire.providerName}
+
+
+
+ {wire.events > 0 ? wire.events : '—'} +
+
+
+
{wire.targetName}
+
{wire.targetType}
+
+ +
+
+ {/each} +
+
{/if} + + +
+
+

+ {t('dashboard.composeTitle')} {t('dashboard.composeEmphasis')} +

+

{t('dashboard.composeSub')}

+
+ +
{/if} confirmClearEvents = false} />