From 153768fb2163671c7243fe214a6aab467d1bec74 Mon Sep 17 00:00:00 2001 From: Alexandr Stelnykovych Date: Wed, 15 Oct 2025 15:23:11 +0300 Subject: [PATCH] UI: Automatic reloading of the connections list https://github.com/safing/portmaster/issues/2039 --- .../shared/netquery/netquery.component.html | 55 ++++++++++-- .../app/shared/netquery/netquery.component.ts | 87 +++++++++++++++++-- .../src/app/shared/pipes/time-ago.pipe.ts | 2 +- 3 files changed, 130 insertions(+), 14 deletions(-) diff --git a/desktop/angular/src/app/shared/netquery/netquery.component.html b/desktop/angular/src/app/shared/netquery/netquery.component.html index 6b5dfbf0..dac7211c 100644 --- a/desktop/angular/src/app/shared/netquery/netquery.component.html +++ b/desktop/angular/src/app/shared/netquery/netquery.component.html @@ -268,17 +268,58 @@ -
+ +
+ {{ totalResultCount }} Results of {{totalConnCount}} total connections - - Last Reload: {{ lastReload | timeAgo:(lastReloadTicker|async) }} - + +
+
+ + + +
+ + + {{autoReloadIntervalName}} + + + + (in {{interval}} sec) + + + + + + + Last Reload: {{ lastReload | timeAgo:(lastReloadTicker|async) }} + + + + + +
+
+ + + + {{value}} + + +
+
+
diff --git a/desktop/angular/src/app/shared/netquery/netquery.component.ts b/desktop/angular/src/app/shared/netquery/netquery.component.ts index a09d18eb..a0d4aea8 100644 --- a/desktop/angular/src/app/shared/netquery/netquery.component.ts +++ b/desktop/angular/src/app/shared/netquery/netquery.component.ts @@ -5,8 +5,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { BandwidthChartResult, ChartResult, Condition, Database, FeatureID, GreaterOrEqual, IPScope, LessOrEqual, Netquery, NetqueryConnection, OrderBy, Pin, PossilbeValue, Query, QueryResult, SPNService, Select, Verdict } from "@safing/portmaster-api"; import { Datasource, DynamicItemsPaginator, SelectOption } from "@safing/ui"; -import { BehaviorSubject, Observable, Subject, combineLatest, forkJoin, interval, of } from "rxjs"; -import { catchError, debounceTime, filter, map, share, skip, switchMap, take, takeUntil } from "rxjs/operators"; +import { BehaviorSubject, Observable, Subject, combineLatest, forkJoin, interval, of, timer } from "rxjs"; +import { catchError, debounceTime, filter, map, share, skip, startWith, switchMap, take, takeUntil } from "rxjs/operators"; import { ActionIndicatorService } from "../action-indicator"; import { ExpertiseService } from "../expertise"; import { objKeys } from "../utils"; @@ -62,6 +62,15 @@ const orderByKeys: (keyof Partial)[] = [ 'profile', ] +export const reloadIntervalValues: { [key: string]: number } = { + "⏸\u00A0\u00A0Don't auto-reload": 0, + "↻\u00A0\u00A0Reload every 10 seconds": 10, + "↻\u00A0\u00A0Reload every 30 seconds": 30, + "↻\u00A0\u00A0Reload every 1 minute": 60, + "↻\u00A0\u00A0Reload every 5 minutes": 300, + "↻\u00A0\u00A0Reload every 30 minutes": 1800, +} + interface LocalQueryResult extends QueryResult { _chart: Observable | null; _group: Observable> | null; @@ -246,6 +255,48 @@ export class SfngNetqueryViewer implements OnInit, OnDestroy, AfterViewInit { share() ) + + /** Auto-reload: The list of all intervals */ + readonly reloadIntervals = Object.keys(reloadIntervalValues); + /** Auto-reload: The name of the currently selected auto-reload interval */ + autoReloadIntervalName: string = ''; + /** Auto-reload: The timestamp of auto-reload being enabled */ + autoReloadEnabledTimestamp: Date | null = null; + /** Auto-reload: Enable/disable auto-reload and set the interval */ + onAutoRefreshChange(intervalName: string) { + let delaySec = reloadIntervalValues[intervalName] || 0; + if (delaySec <= 0) { + this.autoReloadIntervalName = ''; + return; + } + this.autoReloadEnabledTimestamp = new Date(); + this.autoReloadIntervalName = intervalName; + } + /** Auto-reload: An observable that emits the remaining seconds until the next reload, and triggers reloads*/ + autoReloadInterval$ = interval(900) // use less than 1 second to prevent skipping a second + .pipe( + startWith(0), // Emit immediately when subscribed + takeUntilDestroyed(this.destroyRef), + filter(() => !!this.autoReloadIntervalName), // Only emit when auto-reload is enabled + map(() => { + if (this.loading) return 0; + + const intervalSeconds = reloadIntervalValues[this.autoReloadIntervalName] || 0; + if (intervalSeconds <= 0) return 0; + + let startTime = (this.autoReloadEnabledTimestamp && this.autoReloadEnabledTimestamp > this.lastReload) ? this.autoReloadEnabledTimestamp : this.lastReload; + const elapsedSeconds = Math.floor((new Date().getTime() - startTime.getTime()) / 1000); + const remainingSeconds = intervalSeconds - elapsedSeconds; + + if (remainingSeconds <= 0) { + this.performSearch(); // Trigger reload when time is up + return 0; + } + return remainingSeconds; + }), + share() + ); + // whether or not the history database should be queried as well. get useHistory() { return this.dateFilter?.length; @@ -552,8 +603,8 @@ export class SfngNetqueryViewer implements OnInit, OnDestroy, AfterViewInit { // The actual searching is debounced by second so we don't flood the Portmaster service // with queries while the user is still configuring their filters. this.search$ - .pipe( - debounceTime(1000), + .pipe( + this.adaptiveDebounce(1000, () => this.lastReload.getTime()), switchMap(() => { this.loading = true; this.connectionChartData = []; @@ -659,6 +710,7 @@ export class SfngNetqueryViewer implements OnInit, OnDestroy, AfterViewInit { } this.skipUrlUpdate = false; + this.lastReload = new Date(); this.loading = false; this.cdr.markForCheck(); }) @@ -810,6 +862,30 @@ export class SfngNetqueryViewer implements OnInit, OnDestroy, AfterViewInit { this.helper.dispose(); } +/** + * Delays emissions only when last operation was recent. + * + * @param minDelayMs - Minimum milliseconds between operations + * @param getLastOperationTime - Function returning last operation timestamp + * + * @example + * // Delay search only if last search was < 1 second ago + * this.search$.pipe(adaptiveDebounce(1000, () => this.lastReload.getTime())) + */ + adaptiveDebounce( minDelayMs: number, getLastOperationTime: () => number) { + return (source: Observable) => source.pipe( + switchMap((value) => { + const timeSinceLastOperation = Date.now() - getLastOperationTime(); + if (timeSinceLastOperation >= minDelayMs) { + return of(value); // Execute immediately + } else { + const remainingDelay = minDelayMs - timeSinceLastOperation; + return timer(remainingDelay).pipe(map(() => value)); + } + }) + ); +} + // lazyLoadGroup returns an observable that will emit a DynamicItemsPaginator once subscribed. // This is used in "group-by" views to lazy-load the content of the group once the user // expands it. @@ -1001,8 +1077,7 @@ export class SfngNetqueryViewer implements OnInit, OnDestroy, AfterViewInit { /** @private Query the portmaster service for connections matching the current settings */ performSearch() { - this.loading = true; - this.lastReload = new Date(); + this.loading = true; this.paginator.clear() this.search$.next(); this.updateTagbarValues(); diff --git a/desktop/angular/src/app/shared/pipes/time-ago.pipe.ts b/desktop/angular/src/app/shared/pipes/time-ago.pipe.ts index 25f53ac7..a0c2821e 100644 --- a/desktop/angular/src/app/shared/pipes/time-ago.pipe.ts +++ b/desktop/angular/src/app/shared/pipes/time-ago.pipe.ts @@ -52,5 +52,5 @@ export function timeAgo(value: number | Date | string) { } } - return "< 1 min" + suffix // actually just now (diffInSeconds == 0) + return "< 1 min " + suffix // actually just now (diffInSeconds == 0) }