Files
portmaster/desktop/angular/src/app/shared/netquery/netquery.component.ts
Alexandr Stelnykovych 8190e66524 Merge pull request #2040 from safing/feature/2039-UI-auto-reload-connections
Feature: UI auto reload connections
2025-10-18 00:04:44 +03:00

1357 lines
41 KiB
TypeScript

import { coerceArray } from "@angular/cdk/coercion";
import { FormatWidth, formatDate, getLocaleDateFormat, getLocaleId } from "@angular/common";
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, Input, LOCALE_ID, OnDestroy, OnInit, Output, QueryList, TemplateRef, TrackByFunction, ViewChildren, inject, isDevMode } from "@angular/core";
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, 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";
import { fadeInAnimation } from './../animations';
import { IPScopeNames, LocalAppProfile, NetqueryHelper } from "./connection-helper.service";
import { SfngSearchbarFields } from "./searchbar";
import { SfngTagbarValue } from "./tag-bar";
import { Parser } from "./textql";
import { connectionFieldTranslation, mergeConditions } from "./utils";
import { DefaultBandwidthChartConfig } from "./line-chart/line-chart";
import { INTEGRATION_SERVICE } from "src/app/integration";
interface Suggestion<T = any> extends PossilbeValue<T> {
count: number;
selected?: boolean;
}
interface Model<T> {
suggestions: Suggestion<T>[];
searchValues: any[];
visible: boolean | 'combinedMenu';
menuTitle?: string;
loading: boolean;
tipupKey?: string;
virtual?: boolean;
}
const freeTextSearchFields: (keyof Partial<NetqueryConnection>)[] = [
'domain',
'as_owner',
'path',
'profile_name',
'remote_ip'
]
const groupByKeys: (keyof Partial<NetqueryConnection>)[] = [
'domain',
'as_owner',
'country',
'direction',
'path',
'profile'
]
const orderByKeys: (keyof Partial<NetqueryConnection>)[] = [
'domain',
'as_owner',
'country',
'direction',
'path',
'started',
'ended',
'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;
__profile?: LocalAppProfile;
__exitNode?: Pin;
}
interface QuickDateSetting {
name: string;
apply: () => [Date, Date];
}
/**
* Netquery Viewer
*
* This component is the actual viewer component for the netquery subsystem of the Portmaster.
* It allows the user to specify connection filters in multiple different ways and allows
* to do a deep-dive into all connections seen by the Portmaster (that are still stored in
* the in-memory SQLite database of the netquery subsystem).
*
* The user is able to modify the filter query by either:
* - using the available drop-downs
* - using the searchbar which
* - supports typed searches for connection fields (i.e. country:AT domain:google.at)
* - free-text search across the list of supported "full-text" search fields (see freeTextSearchFields)
* - by shift-clicking any value that has a SfngAddToFilter directive
* - by removing values from the tag bar.
*/
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'sfng-netquery-viewer',
templateUrl: './netquery.component.html',
providers: [
NetqueryHelper,
],
styles: [
`
:host {
@apply flex flex-col gap-3 pr-3 min-h-full;
}
.protip pre {
@apply inline-block text-xxs uppercase rounded-sm bg-gray-500 bg-opacity-25 font-mono border-gray-500 border px-0.5;
}
`
],
animations: [
fadeInAnimation
],
changeDetection: ChangeDetectionStrategy.OnPush
})
// eslint-disable-next-line @angular-eslint/component-class-suffix
export class SfngNetqueryViewer implements OnInit, OnDestroy, AfterViewInit {
/** @private Used to trigger a reload of the current filter */
private search$ = new Subject<void>();
/** @private The DestroyRef of the component, required for takeUntilDestroyed */
private destroyRef = inject(DestroyRef);
/** @private Used to trigger an update of all displayed values in the tag-bar. */
private updateTagBar$ = new BehaviorSubject<void>(undefined);
/** @private Whether or not the next update on ActivatedRoute should be ignored */
private skipNextRouteUpdate = false;
/** @private Whether or not we should update the URL when performSearch() finishes */
private skipUrlUpdate = false;
/** @private The LOCALE_ID to format dates. */
private localeId = inject(LOCALE_ID);
private integration = inject(INTEGRATION_SERVICE);
/** @private the date format for the nz-range-picker */
dateFormat = getLocaleDateFormat(getLocaleId(this.localeId), FormatWidth.Medium)
/** @private A list of quick-date settings for the nz-range-picker */
quickDateSettings: QuickDateSetting[] = [
{
name: 'Today',
apply: () => {
const now = new Date();
return [
new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0),
new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, -1),
]
}
},
{
name: 'Last 24 Hours',
apply: () => {
const now = new Date();
return [
new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() - 24, now.getMinutes(), now.getSeconds()),
now
]
}
},
{
name: 'Last 7 Days',
apply: () => {
const now = new Date();
return [
new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, now.getHours(), now.getMinutes(), now.getSeconds()),
now,
]
}
},
{
name: 'Last Month',
apply: () => {
const now = new Date();
return [
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds()),
now,
]
}
},
]
applyQuickDateSetting(qds: QuickDateSetting) {
const [from, to] = qds.apply()
const fromStr = formatDate(from, 'medium', this.localeId)
const toStr = formatDate(to, 'medium', this.localeId)
this.onFieldsParsed({
from: [fromStr],
to: [toStr]
}, true)
}
/** @private - The paginator used for the result set */
paginator!: DynamicItemsPaginator<LocalQueryResult>;
/** @private - The total amount of connections without the filter applied */
totalConnCount: number = 0;
/** @private - The total amount of connections with the filter applied */
totalResultCount: number = 0;
/** The value of the free-text search */
textSearch: string = '';
/** The date filter */
dateFilter: Date[] = []
/** a list of allowed group-by keys */
readonly allowedGroupBy = groupByKeys;
/** a list of allowed order-by keys */
readonly allowedOrderBy = orderByKeys;
/** @private Whether or not we are currently loading data */
loading = false;
/** @private The connection chart data */
connectionChartData: ChartResult[] = [];
/** @private The bandwidth chart data */
bwChartData: BandwidthChartResult<any>[] = [];
/** @private The configuration for the bandwidth chart */
readonly bwChartConfig = DefaultBandwidthChartConfig;
/** @private The list of "pro-tips" that are defined in the template. Only one pro-tip will be rendered depending on proTipIdx */
@ViewChildren('proTip', { read: TemplateRef })
proTips!: QueryList<TemplateRef<any>>
/** @private The index of the pro-tip that is currently rendered. */
proTipIdx = 0;
/** @private The last time the connections were loaded */
lastReload: Date = new Date();
/** @private Used to refresh the "Last reload xxx ago" message */
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;
}
private get databases(): Database[] {
if (!this.useHistory) {
return [Database.Live];
}
return [Database.Live, Database.History];
}
// whether or not the current use has the history feature available.
canUseHistory$ = inject(SPNService).profile$
.pipe(
map(profile => {
if (!profile) {
return false;
}
return profile.current_plan?.feature_ids?.includes(FeatureID.History) || false;
})
);
featureBw$ = inject(SPNService).profile$
.pipe(
map(profile => {
if (!profile) {
return false;
}
return profile.current_plan?.feature_ids?.includes(FeatureID.Bandwidth) || false;
})
);
trackPageItem: TrackByFunction<LocalQueryResult> = (_, r) => {
if (this.groupByKeys?.length) {
return this.groupByKeys.map(key => r[key]).join('-')
}
return r.id!
}
trackConnection: TrackByFunction<NetqueryConnection> = (_, c) => c.id
constructor(
private netquery: Netquery,
private helper: NetqueryHelper,
private expertise: ExpertiseService,
private cdr: ChangeDetectorRef,
private actionIndicator: ActionIndicatorService,
private route: ActivatedRoute,
public router: Router,
) { }
@Input()
set filters(v: any | keyof this['models'] | (keyof this['models'])[]) {
v = coerceArray(v);
objKeys(this.models).forEach(key => {
// ignore any models that are marked as being shown in the combined-menu.
if (this.models[key]?.visible !== 'combinedMenu') {
this.models[key]!.visible = false;
}
})
v.forEach((val: any) => {
if (typeof val !== 'string') {
throw new Error("invalid value for @Input() filters")
}
if (!this.isValidFilter(val)) {
throw new Error('invalid filter key ' + val)
}
this.models[val]!.visible = true;
})
}
/**
* mergeFilter input can be used to apply an additional filter condition that cannot be modified by
* the user (like forcing a "profile" filter for the App View)
*/
@Input()
mergeFilter: Condition | null = null;
/** The filter preset that will be used if no filter is configured otherwise */
@Input()
filterPreset: string | null = null;
@Output()
filterChange: EventEmitter<string> = new EventEmitter();
/** @private Holds the value displayed in the tag-bar */
tagbarValues: SfngTagbarValue[] = [];
private updateDateRangeState() {
const values = [
this.models.from.searchValues[0],
this.models.to.searchValues[0],
]
let fromValueTs = Date.parse(values[0])
let toValueTs = Date.parse(values[1])
// if we failed to parse the date from a string, the user might
// just entered the timestamp in seconds
if (isNaN(fromValueTs)) {
fromValueTs = Number(values[0]) * 1000
}
if (isNaN(toValueTs)) {
toValueTs = Number(values[1]) * 1000
}
const fromValid = !isNaN(fromValueTs)
const toValid = !isNaN(toValueTs)
let fromValue = new Date(fromValueTs)
let toValue = new Date(toValueTs);
if (fromValid && toValid && fromValue.getTime() === toValue.getTime()) {
fromValue = new Date(fromValue.getFullYear(), fromValue.getMonth(), fromValue.getDate(), 0, 0, 0)
toValue = new Date(toValue.getFullYear(), toValue.getMonth(), toValue.getDate() + 1, 0, 0, -1)
}
this.dateFilter = [];
if (fromValid) {
this.dateFilter.push(fromValue)
this.models.from.searchValues = [
formatDate(fromValue, 'medium', this.localeId)
]
}
if (toValid) {
if (!fromValid) {
this.dateFilter.push(new Date(2000, 0, 1))
}
this.dateFilter.push(toValue)
this.models.to.searchValues = [
formatDate(toValue, 'medium', this.localeId)
]
}
this.cdr.markForCheck();
}
private getDateRangeCondition(): Condition | null {
this.updateDateRangeState()
if (!this.dateFilter.length) {
return null
}
const cond: GreaterOrEqual & Partial<LessOrEqual> = {
$ge: Math.floor(this.dateFilter[0].getTime() / 1000),
}
if (this.dateFilter.length >= 2) {
cond['$le'] = Math.floor(this.dateFilter[1].getTime() / 1000)
}
return {
started: cond
}
}
models: { [key: string]: Model<any> } = initializeModels({
domain: {
visible: true,
},
as_owner: {
visible: true,
},
country: {
visible: true,
},
profile: {
visible: true
},
allowed: {
visible: true,
},
path: {},
internal: {},
type: {},
encrypted: {},
scope: {
visible: 'combinedMenu',
menuTitle: 'Network Scope',
suggestions: objKeys(IPScopeNames)
.sort()
.filter(key => key !== IPScope.Undefined)
.map(scope => {
return {
Name: IPScopeNames[scope],
Value: scope,
count: 0,
Description: ''
}
})
},
verdict: {},
started: {},
ended: {},
profile_revision: {},
remote_ip: {},
remote_port: {},
local_ip: {},
local_port: {},
ip_protocol: {},
direction: {
visible: 'combinedMenu',
menuTitle: 'Direction',
suggestions: [
{
Name: 'Inbound',
Value: 'inbound',
Description: '',
count: 0,
},
{
Name: 'Outbound',
Value: 'outbound',
Description: '',
count: 0,
}
]
},
exit_node: {},
asn: {},
active: {
visible: 'combinedMenu',
menuTitle: 'Active',
suggestions: booleanSuggestionValues(),
},
tunneled: {
visible: 'combinedMenu',
menuTitle: 'SPN',
suggestions: booleanSuggestionValues(),
tipupKey: 'spn'
},
from: {
virtual: true
},
to: {
virtual: true,
},
})
/** Translations for the connection field names */
keyTranslation = connectionFieldTranslation;
/** A list of keys for group-by */
groupByKeys: string[] = [];
/** A list of keys for sorting */
orderByKeys: string[] = [];
ngOnInit(): void {
// Prepare the datasource that is used to initialize the DynamicItemPaginator.
// It basically has a "view" function that executes the current page query
// but with page-number and page-size applied.
// This is used by the paginator to support lazy-loading the different
// result pages.
const dataSource: Datasource<LocalQueryResult> = {
view: (page: number, pageSize: number) => {
const query = this.getQuery();
query.page = page - 1; // UI starts at page 1 while the backend is 0-based
query.pageSize = pageSize;
return this.netquery.query(query, 'netquery-viewer')
.pipe(
this.helper.attachProfile(),
this.helper.attachPins(),
map(results => {
return (results || []).map(r => {
const grpFilter: Condition = {
...query.query,
};
this.groupByKeys.forEach(key => {
grpFilter[key] = r[key];
})
let page = {
...r,
_chart: !!this.groupByKeys.length ? this.getGroupChart(grpFilter) : null,
_group: !!this.groupByKeys.length ? this.lazyLoadGroup(grpFilter) : null,
}
return page;
});
})
);
}
}
// create a new paginator that will use the datasource from above.
this.paginator = new DynamicItemsPaginator(dataSource)
// subscribe to the search observable that emits a value each time we want to perform
// a new query.
// 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(
this.adaptiveDebounce(1000, () => this.lastReload.getTime()),
switchMap(() => {
this.loading = true;
this.connectionChartData = [];
this.bwChartData = [];
this.cdr.detectChanges();
const query = this.getQuery();
// we only load the overall connection chart, the total connection count for the filter result
// as well the the total connection count without any filters here. The actual results are
// loaded by the DynamicItemsPaginator using the "view" function defined above.
return forkJoin({
query: of(query),
response: this.netquery.batch({
totalCount: {
...query,
select: { $count: { field: '*', as: 'totalCount' } },
},
totalConnCount: {
...query,
select: {
$count: { field: '*', as: 'totalConnCount' }
},
}
})
.pipe(
map(response => {
// the the correct resulsts here which depend on whether or not
// we're applying a group by.
let totalCount = 0;
if (response?.totalCount) {
if (this.groupByKeys.length === 0 ) {
totalCount = response.totalCount[0].totalCount;
} else {
totalCount = response.totalCount.length;
}
}
return {
totalCount,
totalConnCount: response?.totalConnCount || [],
}
})
),
})
}),
)
.subscribe(result => {
this.paginator.pageLoading$
.pipe(
skip(1),
takeUntil(this.search$), // skip loading the chart if the user trigger a subsequent search
filter(loading => !loading),
take(1),
switchMap(() => forkJoin({
connectionChart: this.netquery.activeConnectionChart(result.query.query!)
.pipe(
catchError(err => {
this.actionIndicator.error(
'Internal Error',
'Failed to load chart: ' + this.actionIndicator.getErrorMessgae(err)
);
return of([] as ChartResult[]);
}),
),
bwChart: this.netquery.bandwidthChart(result.query.query!, [], 60)
})),
)
.subscribe(chart => {
this.connectionChartData = chart.connectionChart;
this.bwChartData = chart.bwChart;
this.cdr.markForCheck();
})
// reset the paginator with the new total result count and
// open the first page.
this.paginator.reset(result.response.totalCount);
this.totalConnCount = result.response?.totalConnCount[0]?.totalConnCount || 0;
this.totalResultCount = result.response.totalCount;
// update the current URL to include the new search
// query and make sure we skip the parameter-update emitted by
// router.
if (!this.skipUrlUpdate) {
this.skipNextRouteUpdate = true;
const queryText = this.getQueryString();
this.filterChange.next(queryText);
// note that since we only update the query parameters and stay on
// the current route this component will not get re-created but will
// rather receive an update on the queryParamMap (see below).
this.router.navigate([], {
relativeTo: this.route,
queryParams: {
...this.route.snapshot.queryParams,
q: queryText,
},
})
}
this.skipUrlUpdate = false;
this.lastReload = new Date();
this.lastReloadTickerForceUpdate$.next();
this.loading = false;
this.cdr.markForCheck();
})
// subscribe to router updates so we apply the filter that is part of
// the current query parameter "q".
// We might ignore updates here depending on the value of "skipNextRouterUpdate".
// This is required as we keep the route parameters in sync with the current filter.
this.route.queryParamMap
.pipe(
takeUntilDestroyed(this.destroyRef),
)
.subscribe(params => {
if (this.skipNextRouteUpdate) {
this.skipNextRouteUpdate = false;
return;
}
const query = params.get("q")
if (query !== null) {
objKeys(this.models).forEach(key => {
this.models[key]!.searchValues = [];
})
const result = Parser.parse(query!)
this.onFieldsParsed({
...result.conditions,
groupBy: result.groupBy,
orderBy: result.orderBy,
});
this.textSearch = result.textQuery;
}
this.skipUrlUpdate = true;
this.performSearch();
})
// we might get new search values from our helper service
// in case the user "SHIFT-Clicks" a SfngAddToFilter directive.
this.helper.onFieldsAdded()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(fields => this.onFieldsParsed(fields))
// updateTagBar$ always emits a value when we need to update the current tag-bar values.
// This must always be done if the current search filter has been modified in either of
// the supported ways.
this.updateTagBar$
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap(() => {
const obs: Observable<{ [key: string]: (PossilbeValue & QueryResult)[] }>[] = [];
// for the tag bar we try to show some pretty names for values that are meant to be
// internal (like the number-constants for the verdicts or using the profile name instead
// of the profile ID). Since we might need to load data from the Portmaster for this (like
// for profile names) we construct a list of observables using helper.encodeToPossibleValues
// and use the result for the tagbar.
Object.keys(this.models)
.sort() // make sure we always output values in a constant order
.forEach(modelKey => {
const values = this.models[modelKey]!.searchValues;
if (values.length > 0) {
obs.push(
of(values.map(val => ({
[modelKey]: val,
})))
.pipe(
this.helper.encodeToPossibleValues(modelKey),
map(result => ({
[modelKey]: result,
}))
)
)
}
})
if (obs.length === 0) {
return of([]);
}
return combineLatest(obs);
})
)
.subscribe(tagBarValues => {
this.tagbarValues = [];
// reset the "selected" field of each model that is shown in the "combinedMenu".
// we'll set the correct ones as "selected" again in the next step.
objKeys(this.models).forEach(key => {
if (this.models[key]?.visible === 'combinedMenu') {
this.models[key]?.suggestions.forEach(val => val.selected = false);
}
})
// finally construct a new list of tag-bar values and update the "selected" field of
// suggested-values for the "combinedMenu" items based on the actual search values.
tagBarValues.forEach(obj => {
objKeys(obj).forEach(key => {
if (obj[key].length > 0) {
this.tagbarValues.push({
key: key as string,
values: obj[key],
})
// update the `selected` field of suggested-values for each model that is displayed in the combined-menu
const modelsKey = key as keyof NetqueryConnection;
if (this.models[modelsKey]?.visible === 'combinedMenu')
this.models[modelsKey]?.suggestions.forEach(suggestedValue => {
suggestedValue.selected = obj[key].some(val => val.Value === suggestedValue.Value);
})
}
})
})
this.cdr.markForCheck();
})
// handle any filter preset
//
if (!!this.filterPreset) {
try {
const result = Parser.parse(this.filterPreset);
this.onFieldsParsed({
...result.conditions,
groupBy: result.groupBy,
orderBy: result.orderBy,
});
} catch (err) {
// only log the error in dev mode as this is most likely
// just bad user input
if (isDevMode()) {
console.error(err);
}
}
}
}
ngAfterViewInit(): void {
// once we are initialized decide which pro-tip we want to show this time...
this.proTipIdx = Math.floor(Math.random() * this.proTips.length);
}
ngOnDestroy() {
this.paginator.clear();
this.search$.complete();
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.
lazyLoadGroup(groupFilter: Condition): Observable<DynamicItemsPaginator<NetqueryConnection>> {
return new Observable(observer => {
this.netquery.query({
query: groupFilter,
select: [
{ $count: { field: "*", as: "totalCount" } }
],
orderBy: [
{ field: 'started', desc: true },
{ field: 'ended', desc: true }
],
databases: this.databases,
}, 'netquery-viewer-load-group')
.subscribe(result => {
const paginator = new DynamicItemsPaginator<NetqueryConnection>({
view: (pageNumber: number, pageSize: number) => {
return this.netquery.query({
query: groupFilter,
orderBy: [
{ field: 'started', desc: true },
{ field: 'ended', desc: true }
],
page: pageNumber - 1,
pageSize: pageSize,
databases: this.databases,
}, 'netquery-viewer-group-paginator') as Observable<NetqueryConnection[]>;
}
}, 25)
paginator.reset(result[0]?.totalCount || 0)
observer.next(paginator)
})
})
}
// Returns an observable that loads the current active connection chart using the
// current page query but only for the condition of the displayed group.
getGroupChart(groupFilter: Condition): Observable<ChartResult[]> {
return this.netquery.activeConnectionChart(groupFilter)
}
// loadSuggestion loads possible values for a given connection field
// and updates the "suggestions" field of the correct models entry.
// It also uses helper.encodeToPossibleValues to make sure we show
// pretty names for otherwise "internal" values like verdict constants
// or profile IDs.
loadSuggestion(field: string): void;
loadSuggestion<T extends keyof NetqueryConnection>(field: T) {
const search = this.getQuery([field]);
this.models[field]!.loading = !this.models[field]!.suggestions?.length;
this.netquery.query({
select: [
field,
{
$count: {
field: "*",
as: "count"
},
}
],
query: search.query,
groupBy: [
field,
],
orderBy: [{ field: "count", desc: true }, { field, desc: true }],
databases: this.databases,
}, 'netquery-viewer-load-suggestions')
.pipe(this.helper.encodeToPossibleValues(field))
.subscribe(result => {
this.models[field]!.loading = false;
// create a set that we can use to lookup if a value
// is currently selected.
// This is needed to ensure selected values are sorted to the top.
let currentlySelected = new Set<any>();
this.models[field]!.searchValues.forEach(
val => currentlySelected.add(val)
);
this.models[field]!.suggestions =
result
.sort((a, b) => {
const hasA = currentlySelected.has(a.Value);
const hasB = currentlySelected.has(b.Value);
if (hasA && !hasB) {
return -1;
}
if (hasB && !hasA) {
return 1;
}
return b.count - a.count;
}) as any;
this.cdr.markForCheck();
})
}
sortByCount(a: SelectOption, b: SelectOption) {
return b.data - a.data
}
/** @private Callback for keyboard events on the search-input */
onFieldsParsed(fields: SfngSearchbarFields, replace = false) {
const allowedKeys = new Set<string>(Object.keys(this.models))
objKeys(fields).forEach(key => {
if (key === 'groupBy') {
this.groupByKeys = (fields.groupBy || this.groupByKeys)
.filter(val => {
// an empty value is just filtered out without an error as this is the only
// way to specify "I don't want grouping" via the filter
if (val === '') {
return false;
}
if (!allowedKeys.has(val as any)) {
this.actionIndicator.error("Invalid search query", "Column " + val + " is not allowed for groupby")
return false;
}
return true;
})
return;
}
if (key === 'orderBy') {
this.orderByKeys = (fields.orderBy || this.orderByKeys)
.filter(val => {
if (!allowedKeys.has(val as any)) {
this.actionIndicator.error("Invalid search query", "Column " + val + " is not allowed for orderby")
return false;
}
return true;
})
return;
}
if (!allowedKeys.has(key)) {
this.actionIndicator.error("Invalid search query", "Column " + key + " is not allowed for filtering");
return;
}
if (fields[key]?.length === 0 && replace) {
this.models[key].searchValues = [];
} else {
fields[key]!.forEach(val => {
// quick fix to make sure domains always end in a period.
if (key === 'domain' && typeof val === 'string' && val.length > 0 && !val.endsWith('.')) {
val = `${val}.`
}
if (typeof val === 'object' && '$ne' in val) {
this.actionIndicator.error("NOT conditions are not yet supported")
return;
}
// avoid duplicates
if (this.models[key]!.searchValues.includes(val)) {
return;
}
if (!replace) {
this.models[key]!.searchValues = [
...this.models[key]!.searchValues,
val,
]
} else {
this.models[key]!.searchValues = [val]
}
})
}
this.updateDateRangeState()
})
this.cdr.markForCheck();
this.performSearch();
}
/** @private Query the portmaster service for connections matching the current settings */
performSearch() {
this.loading = true;
this.paginator.clear()
this.search$.next();
this.updateTagbarValues();
}
/** @private Returns the current query in it's string representation */
getQueryString(): string {
let result = '';
objKeys(this.models).forEach(key => {
this.models[key]?.searchValues.forEach(val => {
// we use JSON.stringify here to make sure the value is
// correclty quoted.
result += `${key}:${JSON.stringify(val)} `;
})
})
if (result.length > 0 && this.textSearch.length > 0) {
result += ' '
}
this.groupByKeys.forEach(key => {
result += `groupby:"${key}" `
})
this.orderByKeys.forEach(key => {
result += `orderby:"${key}" `
})
if (result.length > 0 && this.textSearch.length > 0) {
result += ' '
}
result += `${this.textSearch}`
return result;
}
/** @private Copies the current query into the user clipboard */
copyQuery() {
this.integration.writeToClipboard(this.getQueryString())
.then(() => {
this.actionIndicator.success("Query copied to clipboard", 'Go ahead and share your query!')
})
.catch((err) => {
this.actionIndicator.error('Failed to copy to clipboard', this.actionIndicator.getErrorMessgae(err))
})
}
/** @private Clears the current query */
clearQuery() {
objKeys(this.models).forEach(key => {
this.models[key]!.searchValues = [];
})
this.textSearch = '';
this.updateTagbarValues();
this.performSearch();
}
/** @private Constructs a query from the current page settings. Supports excluding certain fields from the query. */
getQuery(excludeFields: string[] = []): Query {
let query: Condition = {}
let textSearch: Query['textSearch'];
const dateQuery = this.getDateRangeCondition()
if (dateQuery !== null) {
query = mergeConditions(query, dateQuery)
}
// create the query conditions for all keys on this.models
Object.keys(this.models).forEach((key: string) => {
if (excludeFields.includes(key)) {
return;
}
if (this.models[key]!.searchValues.length > 0) {
// check if model is virtual, and if, skip adding it to the query
if (this.models[key].virtual) {
return
}
query[key] = {
$in: this.models[key]!.searchValues,
}
}
})
if (this.expertise.currentLevel !== 'developer') {
query["internal"] = {
$eq: false,
}
}
if (this.textSearch !== '') {
textSearch = {
fields: freeTextSearchFields,
value: this.textSearch
}
}
let select: Query['select'] | undefined = undefined;
if (!!this.groupByKeys.length) {
// we always want to show the total and the number of allowed connections
// per group so we need to add those to the select part of the query
select = [
{
$count: {
field: "*",
as: "totalCount",
},
},
{
$sum: {
condition: {
verdict: {
$in: [
Verdict.Accept,
Verdict.RerouteToNs,
Verdict.RerouteToTunnel
],
}
},
as: "countAllowed"
}
},
...this.groupByKeys,
]
}
let normalizedQuery = mergeConditions(query, this.mergeFilter || {})
let orderBy: string[] | OrderBy[] = this.orderByKeys;
if (!orderBy || orderBy.length === 0) {
orderBy = [
{
field: 'started',
desc: true,
},
{
field: 'ended',
desc: true,
}
]
}
return {
select: select,
query: normalizedQuery,
groupBy: this.groupByKeys,
orderBy: orderBy,
textSearch,
databases: this.databases,
}
}
/** @private Updates the current model form all values emited by the tag-bar. */
onTagbarChange(tagKinds: SfngTagbarValue[]) {
objKeys(this.models).forEach(key => {
this.models[key]!.searchValues = [];
});
tagKinds.forEach(kind => {
const key = kind.key as keyof NetqueryConnection;
this.models[key]!.searchValues = kind.values.map(possibleValue => possibleValue.Value);
if (this.models[key]?.visible === 'combinedMenu')
this.models[key]?.suggestions.forEach(val => {
val.selected = this.models[key]!.searchValues.find(searchValue => searchValue === val.Value)
})
})
this.updateDateRangeState();
this.performSearch();
}
onDateRangeChange(event: Date[]) {
if (event.length >= 1) {
event[0] = new Date(event[0].getFullYear(), event[0].getMonth(), event[0].getDate(), 0, 0, 0)
this.onFieldsParsed({ from: [formatDate(event[0], 'medium', this.localeId)] }, true)
} else {
this.onFieldsParsed({ from: [] }, true)
}
if (event.length >= 2) {
event[1] = new Date(event[1].getFullYear(), event[1].getMonth(), event[1].getDate() + 1, 0, 0, -1)
this.onFieldsParsed({ to: [formatDate(event[1], 'medium', this.localeId)] }, true)
} else {
this.onFieldsParsed({ to: [] }, true)
}
}
/** Updates the {@link tagbarValues} from {@link models}*/
private updateTagbarValues() {
this.updateTagBar$.next();
}
private isValidFilter(key: string): key is keyof NetqueryConnection {
return Object.keys(this.models).includes(key);
}
useAsFilter(rec: QueryResult) {
const keys = new Set(objKeys(this.models))
// reset the search values
keys.forEach(key => {
this.models[key]!.searchValues = [];
})
objKeys(rec).forEach(key => {
if (keys.has(key as keyof NetqueryConnection)) {
this.models[key as keyof NetqueryConnection]!.searchValues = [rec[key]];
}
})
// reset the group-by-keys since they don't make any sense anymore.
this.groupByKeys = [];
this.performSearch();
}
/** @private - used by the combined filter menu */
toggleCombinedMenuFilter(key: string, value: Suggestion) {
const k = key as keyof NetqueryConnection;
if (value.selected) {
this.models[k]!.searchValues = this.models[k]?.searchValues.filter(val => val !== value.Value) || [];
} else {
this.models[k]!.searchValues.push(value.Value)
}
this.updateTagbarValues();
this.performSearch();
}
trackSuggestion: TrackByFunction<Suggestion> = (_: number, s: Suggestion) => s.Name + '::' + s.Value;
}
function initializeModels(models: { [key: string]: Partial<Model<any>> }): { [key: string]: Model<any> } {
objKeys(models).forEach(key => {
models[key] = {
suggestions: [],
searchValues: [],
visible: false,
loading: false,
...models[key],
}
})
return models as any;
}
function booleanSuggestionValues(): Suggestion<any>[] {
return [
{
Name: 'Yes',
Value: true,
Description: '',
count: 0,
},
{
Name: 'No',
Value: false,
Description: '',
count: 0,
},
]
}