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.
This commit is contained in:
2026-05-12 14:12:59 +03:00
parent dfd7329177
commit dec0839853
6 changed files with 115 additions and 7 deletions
+10
View File
@@ -120,6 +120,16 @@ export const sortFilterItems = (): GridItem[] => [
{ value: 'oldest', icon: 'mdiSortClockAscending', label: t('dashboard.oldestFirst'), desc: t('gridDesc.oldestFirst') }, { 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) --- // --- Auto-refresh interval (dashboard events list) ---
// //
// Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS // Values are seconds (0 = off). Keep these in sync with REFRESH_OPTIONS
+5
View File
@@ -157,6 +157,9 @@
"eventsLabel": "events", "eventsLabel": "events",
"onWatchTitle": "On", "onWatchTitle": "On",
"onWatchEmphasis": "watch", "onWatchEmphasis": "watch",
"statsModeTitle": "Provider deck stats scope",
"statsModePage": "Page",
"statsModeAll": "All",
"noProviders": "No providers yet.", "noProviders": "No providers yet.",
"addProvider": "Add provider", "addProvider": "Add provider",
"addProviderHint": "Connect a service to start tracking", "addProviderHint": "Connect a service to start tracking",
@@ -1318,6 +1321,8 @@
"refresh30s": "Refresh every 30 seconds", "refresh30s": "Refresh every 30 seconds",
"refresh60s": "Refresh every minute", "refresh60s": "Refresh every minute",
"refresh5m": "Refresh every 5 minutes", "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", "newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top", "oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown", "chatActionNone": "No indicator shown",
+5
View File
@@ -157,6 +157,9 @@
"eventsLabel": "событий", "eventsLabel": "событий",
"onWatchTitle": "На", "onWatchTitle": "На",
"onWatchEmphasis": "слежении", "onWatchEmphasis": "слежении",
"statsModeTitle": "Область статистики провайдеров",
"statsModePage": "Страница",
"statsModeAll": "Все",
"noProviders": "Пока нет провайдеров.", "noProviders": "Пока нет провайдеров.",
"addProvider": "Добавить", "addProvider": "Добавить",
"addProviderHint": "Подключите сервис, чтобы начать слежение", "addProviderHint": "Подключите сервис, чтобы начать слежение",
@@ -1318,6 +1321,8 @@
"refresh30s": "Обновлять каждые 30 секунд", "refresh30s": "Обновлять каждые 30 секунд",
"refresh60s": "Обновлять каждую минуту", "refresh60s": "Обновлять каждую минуту",
"refresh5m": "Обновлять каждые 5 минут", "refresh5m": "Обновлять каждые 5 минут",
"statsModePage": "Учитывать только события на текущей странице",
"statsModeAll": "Учитывать все события под текущими фильтрами",
"newestFirst": "Сначала новые события", "newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события", "oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается", "chatActionNone": "Индикатор не показывается",
+4
View File
@@ -372,6 +372,10 @@ export interface DashboardStatus {
total_events: number; total_events: number;
recent_events: EventLog[]; recent_events: EventLog[];
command_trackers?: number; 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<string, number>;
} }
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github'; export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
+41 -1
View File
@@ -18,7 +18,7 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import EventDetailModal from '$lib/components/EventDetailModal.svelte'; import EventDetailModal from '$lib/components/EventDetailModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.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 { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers'; import { getDescriptor } from '$lib/providers';
@@ -76,6 +76,17 @@
return stored ? parseInt(stored, 10) || 10 : 10; 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. // Auto-refresh: 0 = off, otherwise seconds between refreshes.
// Allowed cadences are defined in ``refreshIntervalItems()`` — keep // Allowed cadences are defined in ``refreshIntervalItems()`` — keep
// this whitelist in sync with that helper so a stale localStorage // this whitelist in sync with that helper so a stale localStorage
@@ -95,6 +106,7 @@
let eventsLoading = $state(false); let eventsLoading = $state(false);
let confirmClearEvents = $state(false); let confirmClearEvents = $state(false);
let refreshSeconds = $state(loadRefreshSeconds()); let refreshSeconds = $state(loadRefreshSeconds());
let providerStatsMode = $state(loadProviderStatsMode());
let selectedEvent = $state<EventLog | null>(null); let selectedEvent = $state<EventLog | null>(null);
// Stagger entry animation should play once on initial load only — // Stagger entry animation should play once on initial load only —
// without this, every pagination/filter change re-runs the cascade // 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)); 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() { async function clearEvents() {
try { try {
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' }); const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
@@ -197,6 +217,7 @@
targets: next.targets, targets: next.targets,
total_events: next.total_events, total_events: next.total_events,
command_trackers: next.command_trackers, command_trackers: next.command_trackers,
provider_event_counts: next.provider_event_counts,
}; };
return; return;
} }
@@ -298,9 +319,21 @@
: displayProviders); : displayProviders);
// === Provider deck — derive activity counts from recent events === // === 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 providerEventCounts = $derived.by(() => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
if (!status) return counts; 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) { for (const ev of status.recent_events) {
const k = ev.provider_name || ''; const k = ev.provider_name || '';
if (!k) continue; if (!k) continue;
@@ -812,11 +845,18 @@
<h2 class="panel-title">{t('dashboard.onWatchTitle')} <em>{t('dashboard.onWatchEmphasis')}</em></h2> <h2 class="panel-title">{t('dashboard.onWatchTitle')} <em>{t('dashboard.onWatchEmphasis')}</em></h2>
<p class="panel-meta"><b>{providerDeck.length}</b> {t('dashboard.providersShort')}</p> <p class="panel-meta"><b>{providerDeck.length}</b> {t('dashboard.providersShort')}</p>
</div> </div>
<div class="panel-head-actions">
<div class="w-32" title={t('dashboard.statsModeTitle')}>
<IconGridSelect items={providerStatsModeItems()}
bind:value={providerStatsMode}
columns={2} compact />
</div>
<button type="button" onclick={() => toggleSection('on_watch')} <button type="button" onclick={() => toggleSection('on_watch')}
class="ghost-icon-btn" class="ghost-icon-btn"
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}> title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} /> <NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button> </button>
</div>
</header> </header>
{#if sectionExpanded.on_watch} {#if sectionExpanded.on_watch}
@@ -77,6 +77,32 @@ async def get_status(
count_query = select(func.count()).select_from(events_query.subquery()) count_query = select(func.count()).select_from(events_query.subquery())
total_events = (await session.exec(count_query)).one() 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 # Sort
if sort == "oldest": if sort == "oldest":
events_query = events_query.order_by(EventLog.created_at.asc()) events_query = events_query.order_by(EventLog.created_at.asc())
@@ -98,8 +124,11 @@ async def get_status(
)).all() )).all()
tracker_name_map = {tid: tname for tid, tname in tracker_rows} 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 = {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] = {} provider_name_map: dict[int, str] = {}
if provider_ids: if provider_ids:
provider_rows = (await session.exec( provider_rows = (await session.exec(
@@ -189,11 +218,26 @@ async def get_status(
return _display_action_name(e) or e.collection_name return _display_action_name(e) or e.collection_name
return 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 { return {
"providers": providers_count, "providers": providers_count,
"trackers": {"total": len(trackers), "active": active_count}, "trackers": {"total": len(trackers), "active": active_count},
"targets": targets_count, "targets": targets_count,
"total_events": total_events, "total_events": total_events,
"provider_event_counts": provider_event_counts,
"recent_events": [ "recent_events": [
{ {
"id": e.id, "id": e.id,