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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Индикатор не показывается",
|
||||
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
export type ReleaseProviderKind = 'disabled' | 'gitea' | 'github';
|
||||
|
||||
@@ -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<EventLog | null>(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<string, number>();
|
||||
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 @@
|
||||
<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>
|
||||
</div>
|
||||
<button type="button" onclick={() => toggleSection('on_watch')}
|
||||
class="ghost-icon-btn"
|
||||
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
|
||||
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
|
||||
</button>
|
||||
<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')}
|
||||
class="ghost-icon-btn"
|
||||
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
|
||||
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if sectionExpanded.on_watch}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user