From dec0839853e297bade63b20fe3c2529e223c6c33 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 12 May 2026 14:12:59 +0300 Subject: [PATCH] feat: on-watch stats scope selector (page vs all) Adds an icon selector to the "On watch" provider deck letting users choose between page-scoped stats (legacy) and full-corpus stats that aggregate across every event matching the current filters. Backend returns a new provider_event_counts map alongside the paginated events. --- frontend/src/lib/grid-items.ts | 10 ++++ frontend/src/lib/i18n/en.json | 5 ++ frontend/src/lib/i18n/ru.json | 5 ++ frontend/src/lib/types.ts | 4 ++ frontend/src/routes/+page.svelte | 52 ++++++++++++++++--- .../src/notify_bridge_server/api/status.py | 46 +++++++++++++++- 6 files changed, 115 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/grid-items.ts b/frontend/src/lib/grid-items.ts index 86d65dc..59bf914 100644 --- a/frontend/src/lib/grid-items.ts +++ b/frontend/src/lib/grid-items.ts @@ -120,6 +120,16 @@ export const sortFilterItems = (): GridItem[] => [ { value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') }, ]; +// --- Provider stats scope (dashboard "On watch" deck) --- +// +// Toggles whether the provider deck stats reflect only the events visible +// on the current page or aggregate across all events matching the filters. + +export const providerStatsModeItems = (): GridItem[] => [ + { value: 'page', icon: 'mdiFileDocumentOutline', label: t('dashboard.statsModePage'), desc: t('gridDesc.statsModePage') }, + { value: 'all', icon: 'mdiInfinity', label: t('dashboard.statsModeAll'), desc: t('gridDesc.statsModeAll') }, +]; + // --- Auto-refresh interval (dashboard events list) --- // // Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index a4671ba..881284b 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -157,6 +157,9 @@ "eventsLabel": "events", "onWatchTitle": "On", "onWatchEmphasis": "watch", + "statsModeTitle": "Provider deck stats scope", + "statsModePage": "Page", + "statsModeAll": "All", "noProviders": "No providers yet.", "addProvider": "Add provider", "addProviderHint": "Connect a service to start tracking", @@ -1318,6 +1321,8 @@ "refresh30s": "Refresh every 30 seconds", "refresh60s": "Refresh every minute", "refresh5m": "Refresh every 5 minutes", + "statsModePage": "Count only events on the current page", + "statsModeAll": "Count all events matching the current filters", "newestFirst": "Most recent events on top", "oldestFirst": "Oldest events on top", "chatActionNone": "No indicator shown", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 025db12..5d66f01 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -157,6 +157,9 @@ "eventsLabel": "событий", "onWatchTitle": "На", "onWatchEmphasis": "слежении", + "statsModeTitle": "Область статистики провайдеров", + "statsModePage": "Страница", + "statsModeAll": "Все", "noProviders": "Пока нет провайдеров.", "addProvider": "Добавить", "addProviderHint": "Подключите сервис, чтобы начать слежение", @@ -1318,6 +1321,8 @@ "refresh30s": "Обновлять каждые 30 секунд", "refresh60s": "Обновлять каждую минуту", "refresh5m": "Обновлять каждые 5 минут", + "statsModePage": "Учитывать только события на текущей странице", + "statsModeAll": "Учитывать все события под текущими фильтрами", "newestFirst": "Сначала новые события", "oldestFirst": "Сначала старые события", "chatActionNone": "Индикатор не показывается", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 14cf133..a196fdc 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -372,6 +372,10 @@ export interface DashboardStatus { total_events: number; recent_events: EventLog[]; command_trackers?: number; + /** Provider name → total event count across ALL events matching the + * current filters (ignores pagination). Powers the "On watch" deck + * when the user opts out of page-scoped stats. */ + provider_event_counts?: Record; } export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github'; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index b842149..bb8cad1 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -18,7 +18,7 @@ import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import EventDetailModal from '$lib/components/EventDetailModal.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; - import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items'; + import { eventTypeFilterItems, refreshIntervalItems, sortFilterItems, providerStatsModeItems, providerDefaultIcon } from '$lib/grid-items'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { getDescriptor } from '$lib/providers'; @@ -76,6 +76,17 @@ return stored ? parseInt(stored, 10) || 10 : 10; } + // "On watch" provider deck stats scope. ``'page'`` = derive counts from + // the events visible on the current page (legacy behavior); ``'all'`` = + // use the server-aggregated ``provider_event_counts`` map covering every + // event that matches the active filters. + const PROVIDER_STATS_MODE_KEY = 'dashboard_provider_stats_mode'; + function loadProviderStatsMode(): string { + if (typeof localStorage === 'undefined') return 'page'; + const stored = localStorage.getItem(PROVIDER_STATS_MODE_KEY); + return stored === 'all' ? 'all' : 'page'; + } + // Auto-refresh: 0 = off, otherwise seconds between refreshes. // Allowed cadences are defined in ``refreshIntervalItems()`` — keep // this whitelist in sync with that helper so a stale localStorage @@ -95,6 +106,7 @@ let eventsLoading = $state(false); let confirmClearEvents = $state(false); let refreshSeconds = $state(loadRefreshSeconds()); + let providerStatsMode = $state(loadProviderStatsMode()); let selectedEvent = $state(null); // Stagger entry animation should play once on initial load only — // without this, every pagination/filter change re-runs the cascade @@ -128,6 +140,14 @@ if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_REFRESH_KEY, String(v)); }); + // Persist the provider deck stats mode the same way. + let _providerStatsHydrated = false; + $effect(() => { + const v = providerStatsMode; + if (!_providerStatsHydrated) { _providerStatsHydrated = true; return; } + if (typeof localStorage !== 'undefined') localStorage.setItem(PROVIDER_STATS_MODE_KEY, v); + }); + async function clearEvents() { try { const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' }); @@ -197,6 +217,7 @@ targets: next.targets, total_events: next.total_events, command_trackers: next.command_trackers, + provider_event_counts: next.provider_event_counts, }; return; } @@ -298,9 +319,21 @@ : displayProviders); // === Provider deck — derive activity counts from recent events === + // + // ``providerStatsMode`` controls the scope: ``'page'`` derives counts + // from the visible page (legacy), ``'all'`` uses the server-aggregated + // ``provider_event_counts`` map covering every event under the active + // filters regardless of pagination. const providerEventCounts = $derived.by(() => { const counts = new Map(); if (!status) return counts; + if (providerStatsMode === 'all' && status.provider_event_counts) { + for (const [name, total] of Object.entries(status.provider_event_counts)) { + if (!name) continue; + counts.set(name, total); + } + return counts; + } for (const ev of status.recent_events) { const k = ev.provider_name || ''; if (!k) continue; @@ -812,11 +845,18 @@

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

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

- +
+
+ +
+ +
{#if sectionExpanded.on_watch} diff --git a/packages/server/src/notify_bridge_server/api/status.py b/packages/server/src/notify_bridge_server/api/status.py index fb29bd3..8d3e47d 100644 --- a/packages/server/src/notify_bridge_server/api/status.py +++ b/packages/server/src/notify_bridge_server/api/status.py @@ -77,6 +77,32 @@ async def get_status( count_query = select(func.count()).select_from(events_query.subquery()) total_events = (await session.exec(count_query)).one() + # Aggregate per-provider event counts across ALL matching events (ignoring + # offset/limit) so the "On watch" deck can show full-corpus stats when the + # user opts out of page-scoped stats. Sums ``assets_count`` (falling back to + # 1 per event) to mirror the frontend's per-page derivation. + provider_counts_query = ( + select( + EventLog.provider_id, + EventLog.provider_name, + func.sum(func.coalesce(EventLog.assets_count, 1)).label("total"), + ) + .where(EventLog.user_id == user.id) + .group_by(EventLog.provider_id, EventLog.provider_name) + ) + if event_type: + provider_counts_query = provider_counts_query.where(EventLog.event_type == event_type) + if provider_id is not None: + provider_counts_query = provider_counts_query.where(EventLog.provider_id == provider_id) + if search: + provider_counts_query = provider_counts_query.where( + EventLog.collection_name.contains(search) + | EventLog.tracker_name.contains(search) + | EventLog.action_name.contains(search) + | EventLog.provider_name.contains(search) + ) + provider_counts_rows = (await session.exec(provider_counts_query)).all() + # Sort if sort == "oldest": events_query = events_query.order_by(EventLog.created_at.asc()) @@ -98,8 +124,11 @@ async def get_status( )).all() tracker_name_map = {tid: tname for tid, tname in tracker_rows} - # Resolve live provider names similarly + # Resolve live provider names similarly. Includes IDs from the aggregated + # provider counts so the "all events" deck shows up-to-date names even for + # providers that don't appear on the current page. provider_ids = {e.provider_id for e in event_rows if e.provider_id is not None} + provider_ids.update(pid for pid, _pname, _total in provider_counts_rows if pid is not None) provider_name_map: dict[int, str] = {} if provider_ids: provider_rows = (await session.exec( @@ -189,11 +218,26 @@ async def get_status( return _display_action_name(e) or e.collection_name return e.collection_name + # Build the provider event count map keyed by live provider name (matches + # the frontend's keying scheme). Falls back to the stored snapshot name + # when the provider has been deleted. + provider_event_counts: dict[str, int] = {} + for pid, pname, total in provider_counts_rows: + display_name = ( + provider_name_map.get(pid) if pid is not None else None + ) or pname or "" + if not display_name: + continue + provider_event_counts[display_name] = ( + provider_event_counts.get(display_name, 0) + int(total or 0) + ) + return { "providers": providers_count, "trackers": {"total": len(trackers), "active": active_count}, "targets": targets_count, "total_events": total_events, + "provider_event_counts": provider_event_counts, "recent_events": [ { "id": e.id,