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
-
60,
- 'text-red-300': ((lastReloadTicker|async)||0) > 600
- }">
- Last Reload: {{ lastReload | timeAgo:(lastReloadTicker|async) }}
-
+
+
+
+
+
+
+
+
+
+ {{autoReloadIntervalName}}
+
+
+ 0 && interval <= 30">
+ (in {{interval}} sec)
+
+
+
+
+
+
60,
+ 'text-red-300': ((lastReloadTicker|async)||0) > 600
+ }">
+ 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 458dc387..5442b02b 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, merge, of, timer } from "rxjs";
+import { catchError, 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,13 @@ 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 1 minute": 60,
+ "↻\u00A0\u00A0Reload every 5 minutes": 300,
+}
+
interface LocalQueryResult extends QueryResult {
_chart: Observable | null;
_group: Observable> | null;
@@ -239,13 +246,64 @@ export class SfngNetqueryViewer implements OnInit, OnDestroy, AfterViewInit {
lastReload: Date = new Date();
/** @private Used to refresh the "Last reload xxx ago" message */
- lastReloadTicker = interval(2000)
+ private lastReloadTickerForceUpdate$ = new Subject();
+ lastReloadTicker = merge(
+ interval(2000),
+ this.lastReloadTickerForceUpdate$.pipe(takeUntilDestroyed(this.destroyRef))
+ )
.pipe(
takeUntilDestroyed(this.destroyRef),
map(() => Math.floor((new Date()).getTime() - this.lastReload.getTime()) / 1000),
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) {
+ const 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;
+
+ if (this.dateFilter?.length >= 2 && this.dateFilter[1] && this.dateFilter[1].getTime() <= this.lastReload.getTime()) {
+ // Skip reload when dateFilter[1] (end date) <= lastReload (no new results expected)
+ return 0;
+ }
+
+ const intervalSeconds = reloadIntervalValues[this.autoReloadIntervalName] || 0;
+ if (intervalSeconds <= 0) return 0;
+
+ const 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 +610,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 = [];
@@ -661,6 +719,8 @@ export class SfngNetqueryViewer implements OnInit, OnDestroy, AfterViewInit {
}
this.skipUrlUpdate = false;
+ this.lastReload = new Date();
+ this.lastReloadTickerForceUpdate$.next();
this.loading = false;
this.cdr.markForCheck();
})
@@ -812,6 +872,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.
@@ -1003,8 +1087,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)
}