Skip to content
This repository has been archived by the owner on Mar 29, 2024. It is now read-only.

Commit

Permalink
Add support to merge apps
Browse files Browse the repository at this point in the history
  • Loading branch information
ppacher committed Oct 20, 2023
1 parent ae501a4 commit dcc9bdd
Show file tree
Hide file tree
Showing 9 changed files with 317 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { filter, finalize, map, share, take } from 'rxjs/operators';
import { filter, finalize, map, mergeMap, share, take } from 'rxjs/operators';
import { AppProfile, FlatConfigObject, LayeredProfile, TagDescription, flattenProfileConfig } from './app-profile.types';
import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from './portapi.service';

Expand Down Expand Up @@ -134,15 +134,17 @@ export class AppProfileService {
return this.watchedProfiles.get(key)!;
}

const stream = this.portapi.watch<AppProfile>(key)
.pipe(
finalize(() => {
console.log("watchAppProfile: removing cached profile stream for " + key)
this.watchedProfiles.delete(key);
}),
share({ connector: () => new BehaviorSubject<AppProfile | null>(null), resetOnRefCountZero: true }),
filter(profile => profile !== null),
) as Observable<AppProfile>;
const stream =
this.portapi.get<AppProfile>(key)
.pipe(
mergeMap(() => this.portapi.watch<AppProfile>(key)),
finalize(() => {
console.log("watchAppProfile: removing cached profile stream for " + key)
this.watchedProfiles.delete(key);
}),
share({ connector: () => new BehaviorSubject<AppProfile | null>(null), resetOnRefCountZero: true }),
filter(profile => profile !== null),
) as Observable<AppProfile>;

this.watchedProfiles.set(key, stream);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { HttpClient, HttpParams, HttpResponse } from "@angular/common/http";
import { Inject, Injectable } from "@angular/core";
import { Observable, forkJoin, of } from "rxjs";
import { map, mergeMap } from "rxjs/operators";
import { catchError, map, mergeMap } from "rxjs/operators";
import { AppProfileService } from "./app-profile.service";
import { AppProfile } from "./app-profile.types";
import { DNSContext, IPScope, Reason, TLSContext, TunnelContext, Verdict } from "./network.types";
Expand Down Expand Up @@ -438,7 +438,7 @@ export class Netquery {
const getOrCreate = (id: string) => {
let stats = statsMap.get(id) || {
ID: id,
Name: 'TODO',
Name: 'Deleted',
countAliveConnections: 0,
countAllowed: 0,
countUnpermitted: 0,
Expand Down Expand Up @@ -507,24 +507,32 @@ export class Netquery {
return of(profileCache.get(p.ID)!);
}
return this.profileService.getAppProfile(p.ID)
.pipe(catchError(err => {
return of(null)
}))
}))
.pipe(
map(profiles => {
map((profiles: (AppProfile | null)[]) => {
profileCache = new Map();

let lm = new Map<string, IProfileStats>();
stats.forEach(stat => lm.set(stat.ID, stat));

profiles.forEach(p => {
profileCache.set(`${p.Source}/${p.ID}`, p)
profiles
.forEach(p => {
if (!p) {
return
}

let stat = lm.get(`${p.Source}/${p.ID}`)
if (!stat) {
return;
}
profileCache.set(`${p.Source}/${p.ID}`, p)

stat.Name = p.Name
})
let stat = lm.get(`${p.Source}/${p.ID}`)
if (!stat) {
return;
}

stat.Name = p.Name
})

return Array.from(lm.values())
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,17 @@ export class PortapiService {
})
}

/** Merge multiple profiles into one primary profile. */
mergeProfiles(name: string, primary: string, secondaries: string[]): Observable<string> {
return this.http.post<{ new: string }>(`${this.httpEndpoint}/v1/profile/merge`, {
name: name,
to: primary,
from: secondaries,
}).pipe(
map(response => response.new)
)
}

/**
* Injects an event into a module to trigger certain backend
* behavior.
Expand Down
4 changes: 3 additions & 1 deletion modules/portmaster/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { SPNStatusComponent } from './shared/spn-status';
import { PilotWidgetComponent } from './shared/status-pilot';
import { PlaceholderComponent } from './shared/text-placeholder';
import { DashboardWidgetComponent } from './pages/dashboard/dashboard-widget/dashboard-widget.component';
import { MergeProfileDialogComponent } from './pages/app-view/merge-profile-dialog/merge-profile-dialog.component';

function loadAndSetLocaleInitializer(configService: ConfigService) {
return async function () {
Expand Down Expand Up @@ -155,7 +156,8 @@ const localeConfig = {
QsHistoryComponent,
DashboardPageComponent,
DashboardWidgetComponent,
FeatureCardComponent
FeatureCardComponent,
MergeProfileDialogComponent
],
imports: [
BrowserModule,
Expand Down
46 changes: 31 additions & 15 deletions modules/portmaster/src/app/pages/app-view/app-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from '@angular/router';
import { AppProfile, AppProfileService, BandwidthChartResult, ChartResult, Condition, ConfigService, Database, DebugAPI, ExpertiseLevel, FeatureID, FlatConfigObject, IProfileStats, LayeredProfile, Netquery, ProfileBandwidthChartResult, SPNService, Setting, flattenProfileConfig, setAppSetting } from '@safing/portmaster-api';
import { SfngDialogService } from '@safing/ui';
import { BehaviorSubject, Observable, Subscription, combineLatest, interval, of } from 'rxjs';
import { distinctUntilChanged, map, mergeMap, startWith, switchMap } from 'rxjs/operators';
import { BehaviorSubject, Observable, Subscription, combineLatest, interval, of, throwError } from 'rxjs';
import { catchError, distinctUntilChanged, map, mergeMap, startWith, switchMap } from 'rxjs/operators';
import { SessionDataService } from 'src/app/services';
import { ActionIndicatorService } from 'src/app/shared/action-indicator';
import { fadeInAnimation, fadeOutAnimation } from 'src/app/shared/animations';
Expand Down Expand Up @@ -272,19 +272,35 @@ export class AppViewComponent implements OnInit, OnDestroy {
// Start watching the application profile.
// switchMap will unsubscribe automatically if
// we start watching a different profile.
return combineLatest([
this.profileService.watchAppProfile(source, id),
this.profileService.watchLayeredProfile(source, id)
.pipe(startWith(null)),
interval(10000)
.pipe(
startWith(-1),
mergeMap(() => this.netquery.getProfileStats({
profile: `${source}/${id}`,
}).pipe(map(result => result?.[0]))),
startWith(null),
)
])
return this.profileService.getAppProfile(source, id)
.pipe(
catchError(err => {
if (typeof err === 'string') {
err = new Error(err)
}

this.router.navigate(['/app/overview'], { onSameUrlNavigation: 'reload' })

this.actionIndicator.error('Failed To Get Profile', this.actionIndicator.getErrorMessgae(err))

return throwError(() => err)
}),
mergeMap(() => {
return combineLatest([
this.profileService.watchAppProfile(source, id),
this.profileService.watchLayeredProfile(source, id)
.pipe(startWith(null)),
interval(10000)
.pipe(
startWith(-1),
mergeMap(() => this.netquery.getProfileStats({
profile: `${source}/${id}`,
}).pipe(map(result => result?.[0]))),
startWith(null),
)
])
})
)
})
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<header class="flex flex-row items-center justify-between mb-2">
<h1 class="text-sm font-light m-0">
Merge Profiles
</h1>

<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" class="w-3 h-3 text-secondary hover:text-primary cursor-pointer" (click)="dialogRef.close()">
<path fill="currentColor" d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"></path>
</svg>
</header>

<span class="py-2 text-secondary text-xxs">
Please select the primary profile. All other selected profiles will be merged into the primary profile by copying metadata, fingerprints and icons into a new profile.
Only the settings of the primary profile will be kept.
</span>

<div class="flex flex-row gap-2 justify-between border-b border-gray-500 p-2 items-center">
<label class="text-primary text-xxs relative">Primary Profile:</label>
<sfng-select [(ngModel)]="primary" (ngModelChange)="newName = newName || primary?.Name || ''" class="border border-gray-500">
<ng-container *ngFor="let p of profiles; trackBy: trackProfile">
<sfng-select-item *sfngSelectValue="p; label:p.Name" class="flex flex-row items-center gap-2">
<app-icon [profile]="p"></app-icon>
{{ p.Name }}
</sfng-select-item>
</ng-container>
</sfng-select>
</div>

<div class="flex flex-row gap-2 justify-between items-center p-2">
<label class="text-primary text-xxs relative">Name for the new Profile</label>
<input type="text" [(ngModel)]="newName" placeholder="New Profile Name" class="!border !border-gray-500 flex-grow">
</div>

<div class="flex flex-row justify-end gap-2">
<button (click)="dialogRef.close()">Abort</button>
<button class="bg-blue text-white" (click)="mergeProfiles()" [disabled]="!primary || !newName">Merge</button>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { AppProfile } from './../../../../../dist-lib/safing/portmaster-api/lib/app-profile.types.d';
import { ChangeDetectionStrategy, Component, OnInit, TrackByFunction, inject } from "@angular/core";
import { Router } from '@angular/router';
import { PortapiService } from '@safing/portmaster-api';
import { SFNG_DIALOG_REF, SfngDialogRef } from "@safing/ui";
import { ActionIndicatorService } from 'src/app/shared/action-indicator';

@Component({
templateUrl: './merge-profile-dialog.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
@apply flex flex-col gap-2 justify-start h-96 w-96;
}
`
]
})
export class MergeProfileDialogComponent implements OnInit {
readonly dialogRef: SfngDialogRef<MergeProfileDialogComponent, unknown, AppProfile[]> = inject(SFNG_DIALOG_REF);
private readonly portapi = inject(PortapiService);
private readonly router = inject(Router);
private readonly uai = inject(ActionIndicatorService);

get profiles(): AppProfile[] {
return this.dialogRef.data;
}

primary: AppProfile | null = null;
newName = '';

trackProfile: TrackByFunction<AppProfile> = (_, p) => `${p.Source}/${p.ID}`

ngOnInit(): void {
(() => { });
}

mergeProfiles() {
if (!this.primary) {
return
}

this.portapi.mergeProfiles(
this.newName,
`${this.primary.Source}/${this.primary.ID}`,
this.profiles
.filter(p => p !== this.primary)
.map(p => `${p.Source}/${p.ID}`)
)
.subscribe({
next: newID => {
this.router.navigate(['/app/' + newID])
this.uai.success('Profiles Merged Successfully', 'All selected profiles have been merged')

this.dialogRef.close()
},
error: err => {
this.uai.error('Failed To Merge Profiles', this.uai.getErrorMessgae(err))
}
})
}
}
48 changes: 44 additions & 4 deletions modules/portmaster/src/app/pages/app-view/overview.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,44 @@ <h1>
<sfng-tipup key="appsTitle"></sfng-tipup>
</h1>
<div class="flex-grow"></div>
<button (click)="createProfile()" class="text-white hover:bg-opacity-90">Create
Profile</button>

<app-menu #profileMenu>
<app-menu-item (click)="createProfile()">Create profile</app-menu-item>
<app-menu-item (click)="selectMode = true">Merge or Delete profiles</app-menu-item>
</app-menu>

<div class="flex flex-row gap-2 items-center">
<app-menu-trigger *ngIf="!selectMode" [menu]="profileMenu" useContent="true">
<div class="flex flex-row gap-2 items-center text-xs font-light">
Manage

<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
</svg>
</div>
</app-menu-trigger>

<ng-container *ngIf="selectMode">

<app-menu #selectionMenu>
<app-menu-item (click)="openMergeDialog()">Merge Profiles</app-menu-item>
<app-menu-item (click)="deleteSelectedProfiles()">Delete Profiles</app-menu-item>
<app-menu-item (click)="selectMode = false">Abort</app-menu-item>
</app-menu>

<app-menu-trigger [menu]="selectionMenu" useContent="true">
<div class="flex flex-row gap-2 items-center text-xs font-light">
{{ selectedProfileCount}} selected

<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 8.25V6a2.25 2.25 0 00-2.25-2.25H6A2.25 2.25 0 003.75 6v8.25A2.25 2.25 0 006 16.5h2.25m8.25-8.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-7.5A2.25 2.25 0 018.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 00-2.25 2.25v6" />
</svg>

</div>
</app-menu-trigger>
</ng-container>
</div>

</div>

<div class="scrollable" [@fadeInList]="total">
Expand Down Expand Up @@ -44,15 +80,19 @@ <h4>

<ng-template #profileList let-list>
<ng-container *ngFor="let profile of list; trackBy: trackProfile">
<div *appExpertiseLevel="profile.Internal ? 'developer' : 'user'" class="relative card-header"
[routerLink]="['/app', profile.Source, profile.ID]">
<div *appExpertiseLevel="profile.Internal ? 'developer' : 'user'" class="relative card-header" [ngClass]="{'ring-1 ring-inset ring-yellow-300': profile.selected}"
(click)="handleProfileClick(profile, $event)"
[routerLink]="selectMode ? null : ['/app', profile.Source, profile.ID]">
<app-icon [profile]="profile"></app-icon>

<span class="card-title">
<span [innerHTML]="profile?.Name | safe:'html'"></span>
<span class="card-sub-title" *appExpertiseLevel="'expert'"
[innerHTML]="profile?.PresentationPath | safe:'html'"></span>
</span>

<input type="checkbox" *ngIf="selectMode" [(ngModel)]="profile.selected" (click)="$event.stopPropagation()">

<span *ngIf="profile.hasConfigChanges" sfng-tooltip="Settings Edited"
class="absolute w-2 h-2 rounded-full top-1 right-1 bg-blue"></span>
</div>
Expand Down
Loading

0 comments on commit dcc9bdd

Please sign in to comment.