Merge pull request #2040 from safing/feature/2039-UI-auto-reload-connections

Feature: UI auto reload connections
This commit is contained in:
Alexandr Stelnykovych
2025-10-18 00:04:44 +03:00
committed by GitHub
3 changed files with 139 additions and 15 deletions

View File

@@ -268,17 +268,58 @@
</div>
</ng-template>
<div class="flex flex-row items-center justify-start gap-3 mt-3">
<!-- "Total connections & LastReload info "-->
<div class="flex flex-row items-center justify-start gap-3">
<span class="text-xxs text-primary" *ngIf="!loading">{{ totalResultCount }} Results
<span class="text-secondary">of {{totalConnCount}} total connections</span>
</span>
<span class="flex-grow"></span>
<span class="pr-3 text-xxs text-secondary" [ngClass]="{
'text-yellow-300': ((lastReloadTicker|async)||0) > 60,
'text-red-300': ((lastReloadTicker|async)||0) > 600
}">
Last Reload: {{ lastReload | timeAgo:(lastReloadTicker|async) }}
</span>
<div class="flex flex-row items-center">
<div class="flex flex-row">
<!-- Auto-Reload Interval selector -->
<app-menu-trigger [menu]="autoReloadMenu" useContent="true"
class="text-secondary hover:text-primary flex !m-0"
>
<div class="flex flex-row items-center">
<!-- Auto-Reload Interval countdown--->
<span *ngIf="!!autoReloadIntervalName" class="pr-3 text-xxs text-secondary" style="opacity: 0.5;" >
{{autoReloadIntervalName}}
<span *ngIf="(autoReloadInterval$ | async) as interval">
<span *ngIf="interval > 0 && interval <= 30">
(in {{interval}} sec)
</span>
</span>
</span>
<!-- Last Reload Time -->
<span class="pr-3 text-xxs text-secondary" [ngClass]="{
'text-yellow-300': ((lastReloadTicker|async)||0) > 60,
'text-red-300': ((lastReloadTicker|async)||0) > 600
}">
Last Reload: {{ lastReload | timeAgo:(lastReloadTicker|async) }}
</span>
<svg xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</div>
</app-menu-trigger>
<app-menu #autoReloadMenu>
<app-menu-item *ngFor="let value of reloadIntervals" (click)="onAutoRefreshChange(value)">
{{value}}
</app-menu-item>
</app-menu>
</div>
</div>
</div>
<sfng-pagination *ngIf="!loading; else: loadingTemplate" [source]="paginator" class="flex flex-col">

View File

@@ -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<NetqueryConnection>)[] = [
'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<ChartResult[]> | null;
_group: Observable<DynamicItemsPaginator<NetqueryConnection>> | 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<void>();
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<T>( minDelayMs: number, getLastOperationTime: () => number) {
return (source: Observable<T>) => 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();

View File

@@ -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)
}