Migrate Angular UI from portmaster-ui to desktop/angular. Update Earthfile to build libs, UI and tauri-builtin

This commit is contained in:
Patrick Pacher
2024-03-20 10:43:29 +01:00
parent 66381baa1a
commit 4b77945517
922 changed files with 84071 additions and 26 deletions

View File

@@ -0,0 +1,68 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AppViewComponent } from './pages/app-view';
import { DashboardPageComponent } from './pages/dashboard/dashboard.component';
import { MonitorPageComponent } from './pages/monitor';
import { SettingsComponent } from './pages/settings/settings';
import { SpnPageComponent } from './pages/spn';
import { SupportPageComponent } from './pages/support';
import { SupportFormComponent } from './pages/support/form';
const routes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'dashboard',
},
{
path: 'settings',
component: SettingsComponent,
},
{
path: 'app',
pathMatch: 'full',
redirectTo: 'app/overview',
},
{
path: 'app/overview',
component: AppViewComponent,
},
{
path: 'app/:source/:id',
component: AppViewComponent,
},
{
path: 'monitor',
component: MonitorPageComponent,
},
{
path: 'monitor/profile/:source/:profile',
redirectTo: 'monitor',
},
{
path: 'support',
component: SupportPageComponent,
},
{
path: 'support/:id',
component: SupportFormComponent,
},
{
path: 'spn',
component: SpnPageComponent,
},
{
path: '**',
redirectTo: 'dashboard'
},
{
path: 'dashboard',
component: DashboardPageComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(routes, { anchorScrolling: 'enabled' })],
exports: [RouterModule]
})
export class AppRoutingModule { }

View File

@@ -0,0 +1,53 @@
<app-navigation #navigation (sideDashChange)="onSideDashChange($event)" class="relative block bg-background">
</app-navigation>
<div *ngIf="sideDashStatus === 'expanded' && sideDashOverlay" (click)="navigation.toggleSideDash($event)" [@fadeIn]
[@fadeOut] class="absolute top-0 bottom-0 right-0 left-16 dialog-screen-backdrop-light" style="z-index: 100"></div>
<app-side-dash class="flex-shrink-0" style="z-index: 100"
[ngClass]="{'absolute top-0 left-16 bg-gray-1002 h-full shadow-2xl': sideDashOverlay, 'relative': !sideDashOverlay}"
*ngIf="sideDashStatus === 'expanded'" [@fadeIn] (@fadeIn.done)="windowResizeChange.next()" [@fadeOut]
(@fadeOut.done)="windowResizeChange.next()">
</app-side-dash>
<div class="main" #mainContent>
<router-outlet></router-outlet>
</div>
<div class="loading" *ngIf="(showOverlay$ | async) as overlayText" [@fadeIn] [@fadeOut]>
<div class="message">
<div class="logo" routerLink="monitor">
<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 128 128" class="spin reverse">
<g data-name="Main" fill-rule="evenodd">
<path fill="#fff" class="inner"
d="M176.11 36.73l-5-8.61a41.53 41.53 0 00-14.73 57.22l8.55-5.12a31.58 31.58 0 0111.19-43.49z"
transform="translate(-127.99 .01)" style="isolation:isolate" opacity=".8"></path>
<path fill="#fff" class="inner"
d="M222.36 72.63a31.55 31.55 0 01-45 19.35l-4.62 8.84a41.54 41.54 0 0059.17-25.46z"
transform="translate(-127.99 .01)" style="isolation:isolate" opacity=".8"></path>
</g>
</svg>
<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 128 128" class="spin">
<g data-name="Main" fill-rule="evenodd">
<path fill="#fff" class="inner reverse"
d="M197 83a19.66 19.66 0 01-19.25-32.57l-4.5-4.27A25.87 25.87 0 00198.59 89z"
transform="translate(-127.99 .01)" style="isolation:isolate" opacity=".6"></path>
</g>
</svg>
<svg data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 128 128">
<g data-name="Main" fill-rule="evenodd">
<path fill="#fff"
d="M192 112.64A48.64 48.64 0 11240.64 64 48.64 48.64 0 01192 112.64zM256 64a64 64 0 10-64 64 64 64 0 0064-64z"
transform="translate(-127.99 .1)"></path>
</g>
</svg>
</div>
<h1>{{overlayText}}</h1>
<h1>...</h1>
</div>
</div>

View File

@@ -0,0 +1,114 @@
:host {
display: flex;
@apply bg-background;
@apply h-screen overflow-hidden;
&>* {
flex-shrink: 0;
}
}
app-navigation,
app-side-dash {
@apply border-r;
@apply border-cards-tertiary;
@apply bg-background;
}
app-navigation {
@apply w-16;
}
div.main {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: center;
@apply bg-background;
height: 100vh;
overflow: hidden;
}
app-debug {
@apply border-l;
@apply border-cards-tertiary;
@apply bg-background;
width: 30vw;
height: 100vh;
min-width: 350px;
top: 0px;
position: sticky;
}
.loading {
z-index: 100;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
backdrop-filter: blur(10px);
background-color: rgba(#222222, 0.35);
.message {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
flex-direction: column;
}
svg {
width: 100%;
position: absolute;
top: 0;
left: 0;
}
div.logo {
opacity: 0.8;
position: relative;
width: 10vh;
height: 10vh;
@apply mt-4;
}
.spin {
animation-name: spin;
animation-duration: 3500ms;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
.reverse {
animation-name: spin-reverse;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes spin-reverse {
0% {
transform: rotate(360deg);
}
100% {
transform: rotate(0deg);
}
}

View File

@@ -0,0 +1,28 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule
],
declarations: [
AppComponent
],
}).compileComponents();
}));
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'portmaster'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('portmaster');
});
});

View File

@@ -0,0 +1,234 @@
import { Overlay } from '@angular/cdk/overlay';
import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, HostListener, Inject, NgZone, OnInit, Renderer2, ViewChild } from '@angular/core';
import { Params, Router } from '@angular/router';
import { PortapiService } from '@safing/portmaster-api';
import { OverlayStepper, SfngDialogService, StepperRef } from '@safing/ui';
import { BehaviorSubject, merge, Subject } from 'rxjs';
import { debounceTime, filter, mergeMap, skip, startWith, take } from 'rxjs/operators';
import { IntroModule } from './intro';
import { NotificationsService, UIStateService } from './services';
import { ActionIndicatorService } from './shared/action-indicator';
import { fadeInAnimation, fadeOutAnimation } from './shared/animations';
import { ExitService } from './shared/exit-screen';
import { SfngNetquerySearchOverlayComponent } from './shared/netquery/search-overlay';
import { INTEGRATION_SERVICE, IntegrationService } from './integration';
import { TauriIntegrationService } from './integration/taur-app';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
animations: [
fadeInAnimation,
fadeOutAnimation,
]
})
export class AppComponent implements OnInit, AfterViewInit {
readonly connected = this.portapi.connected$.pipe(
debounceTime(250),
startWith(false)
);
title = 'portmaster';
/** The current status of the side dash as emitted by the navigation component */
sideDashStatus: 'collapsed' | 'expanded' = 'expanded';
/** Whether or not the side-dash is in overlay mode */
sideDashOverlay = false;
/** The MQL to watch for screen size changes. */
private mql!: MediaQueryList;
/** Emits when the side-dash is opened or closed in non-overlay mode */
private sideDashOpen = new BehaviorSubject<boolean>(false);
/** Used to emit when the window size changed */
windowResizeChange = new Subject<void>();
get sideDashOpen$() { return this.sideDashOpen.asObservable() }
get showOverlay$() { return this.exitService.showOverlay$ }
get onContentSizeChange$() {
return merge(
this.windowResizeChange,
this.sideDashOpen$
)
.pipe(
startWith(undefined),
debounceTime(100),
)
}
@ViewChild('mainContent', { read: ElementRef, static: true })
mainContent!: ElementRef<HTMLDivElement>;
@HostListener('window:resize')
onWindowResize() {
this.windowResizeChange.next();
}
@HostListener('document:keydown', ['$event'])
onKeyDown(event: KeyboardEvent) {
if (event.key === ' ' && event.ctrlKey) {
this.dialog.create(
SfngNetquerySearchOverlayComponent,
{
positionStrategy: this.overlay
.position()
.global()
.centerHorizontally()
.top('1rem'),
backdrop: 'light',
autoclose: true,
}
)
return;
}
}
constructor(
public ngZone: NgZone,
public portapi: PortapiService,
public changeDetectorRef: ChangeDetectorRef,
private router: Router,
private exitService: ExitService,
private overlayStepper: OverlayStepper,
private dialog: SfngDialogService,
private overlay: Overlay,
private stateService: UIStateService,
private renderer2: Renderer2,
@Inject(INTEGRATION_SERVICE) private integration: IntegrationService,
) {
(window as any).portapi = portapi;
}
onSideDashChange(state: 'expanded' | 'collapsed' | 'force-overlay') {
if (state === 'force-overlay') {
state = 'expanded';
if (!this.sideDashOverlay) {
this.sideDashOverlay = true;
}
} else {
this.sideDashOverlay = this.mql.matches;
}
this.sideDashStatus = state;
if (!this.sideDashOverlay) {
this.sideDashOpen.next(this.sideDashStatus === 'expanded')
}
}
ngOnInit() {
// default breakpoints used by tailwindcss
const minContentWithBp = [
640, // sfng-sm:
768, // sfng-md:
1024, // sfng-lg:
1280, // sfng-xl:
1536 // sfng-2xl:
]
// prepare our breakpoint listeners and add the classes to our main element
merge(
this.windowResizeChange,
this.sideDashOpen$
)
.pipe(
startWith(undefined),
debounceTime(100),
)
.subscribe(() => {
const rect = (this.mainContent.nativeElement as HTMLElement).getBoundingClientRect();
minContentWithBp.forEach((bp, idx) => {
if (rect.width >= bp) {
this.renderer2.addClass(this.mainContent.nativeElement, `min-width-${bp}px`)
} else {
this.renderer2.removeClass(this.mainContent.nativeElement, `min-width-${bp}px`)
}
})
this.changeDetectorRef.markForCheck();
})
// force a reload of the current route if we reconnected to
// portmaster. This ensures we'll refresh any data that's currently
// displayed.
this.connected
.pipe(
filter(connected => !!connected),
skip(1),
)
.subscribe(async () => {
const location = new URL(window.location.toString());
const params: Params = {}
location.searchParams.forEach((value, key) => {
params[key] = [
...(params[key] || []),
value,
]
})
await this.router.navigateByUrl('/', { skipLocationChange: true })
this.router.navigate([location.pathname], {
queryParams: params,
});
})
this.stateService.uiState()
.pipe(take(1))
.subscribe(state => {
if (!state.introScreenFinished) {
this.showIntro();
}
})
this.mql = window.matchMedia('(max-width: 1200px)');
this.sideDashOverlay = this.mql.matches;
this.mql.addEventListener('change', () => {
this.sideDashOverlay = this.mql.matches;
if (!this.sideDashOverlay) {
this.sideDashOpen.next(this.sideDashStatus === 'expanded')
}
})
}
ngAfterViewInit(): void {
this.sideDashOpen.next(this.sideDashStatus !== 'collapsed')
if (this.integration instanceof TauriIntegrationService) {
let tauri = this.integration;
tauri.shouldShow()
.then(show => {
console.log("should open window: ", show)
if (show) {
tauri.openApp();
}
});
}
}
showIntro(): StepperRef {
const stepperRef = this.overlayStepper.create(IntroModule.Stepper)
stepperRef.onFinish.subscribe(() => {
this.stateService.uiState()
.pipe(
take(1),
mergeMap(state => this.stateService.saveState({
...state,
introScreenFinished: true
}))
)
.subscribe();
})
return stepperRef;
}
}

View File

@@ -0,0 +1,240 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { OverlayModule } from '@angular/cdk/overlay';
import { PortalModule } from '@angular/cdk/portal';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { CdkTableModule } from '@angular/cdk/table';
import { CommonModule, registerLocaleData } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { APP_INITIALIZER, LOCALE_ID, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { FaIconLibrary, FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { far } from '@fortawesome/free-regular-svg-icons';
import { fas } from '@fortawesome/free-solid-svg-icons';
import { ConfigService, PortmasterAPIModule, StringSetting, getActualValue } from '@safing/portmaster-api';
import { OverlayStepperModule, SfngAccordionModule, SfngDialogModule, SfngDropDownModule, SfngPaginationModule, SfngSelectModule, SfngTipUpModule, SfngToggleSwitchModule, SfngTooltipModule, TabModule, UiModule } from '@safing/ui';
import MyYamlFile from 'js-yaml-loader!../i18n/helptexts.yaml';
import * as i18n from 'ng-zorro-antd/i18n';
import { MarkdownModule } from 'ngx-markdown';
import { firstValueFrom } from 'rxjs';
import { environment } from 'src/environments/environment';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { IntroModule } from './intro';
import { NavigationComponent } from './layout/navigation/navigation';
import { SideDashComponent } from './layout/side-dash/side-dash';
import { AppOverviewComponent, AppViewComponent, QuickSettingInternetButtonComponent } from './pages/app-view';
import { QsHistoryComponent } from './pages/app-view/qs-history/qs-history.component';
import { QuickSettingSelectExitButtonComponent } from './pages/app-view/qs-select-exit/qs-select-exit';
import { QuickSettingUseSPNButtonComponent } from './pages/app-view/qs-use-spn/qs-use-spn';
import { DashboardPageComponent } from './pages/dashboard/dashboard.component';
import { FeatureCardComponent } from './pages/dashboard/feature-card/feature-card.component';
import { MonitorPageComponent } from './pages/monitor';
import { SettingsComponent } from './pages/settings/settings';
import { SPNModule } from './pages/spn/spn.module';
import { SupportPageComponent } from './pages/support';
import { SupportFormComponent } from './pages/support/form';
import { NotificationsService } from './services';
import { ActionIndicatorModule } from './shared/action-indicator';
import { SfngAppIconModule } from './shared/app-icon';
import { ConfigModule } from './shared/config';
import { CountIndicatorModule } from './shared/count-indicator';
import { CountryFlagModule } from './shared/country-flag';
import { EditProfileDialog } from './shared/edit-profile-dialog';
import { ExitScreenComponent } from './shared/exit-screen/exit-screen';
import { ExpertiseModule } from './shared/expertise/expertise.module';
import { ExternalLinkDirective } from './shared/external-link.directive';
import { FeatureScoutComponent } from './shared/feature-scout';
import { SfngFocusModule } from './shared/focus';
import { FuzzySearchPipe } from './shared/fuzzySearch';
import { LoadingComponent } from './shared/loading';
import { SfngMenuModule } from './shared/menu';
import { SfngMultiSwitchModule } from './shared/multi-switch';
import { NetqueryModule } from './shared/netquery';
import { NetworkScoutComponent } from './shared/network-scout';
import { NotificationListComponent } from './shared/notification-list/notification-list.component';
import { NotificationComponent } from './shared/notification/notification';
import { CommonPipesModule } from './shared/pipes';
import { ProcessDetailsDialogComponent } from './shared/process-details-dialog';
import { PromptListComponent } from './shared/prompt-list/prompt-list.component';
import { SecurityLockComponent } from './shared/security-lock';
import { SPNAccountDetailsComponent } from './shared/spn-account-details';
import { SPNLoginComponent } from './shared/spn-login';
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';
import { AppInsightsComponent } from './pages/app-view/app-insights/app-insights.component';
import { INTEGRATION_SERVICE, integrationServiceFactory } from './integration';
import { SupportProgressDialogComponent } from './pages/support/progress-dialog';
function loadAndSetLocaleInitializer(configService: ConfigService) {
return async function () {
let angularLocaleID = 'en-GB';
let nzLocaleID: string = 'en_GB';
try {
const setting = await firstValueFrom(configService.get("core/locale"))
const currentValue = getActualValue(setting as StringSetting);
switch (currentValue) {
case 'en-US':
angularLocaleID = 'en-US'
nzLocaleID = 'en_US'
break;
case 'en-GB':
angularLocaleID = 'en-GB'
nzLocaleID = 'en_GB'
break;
default:
console.error(`Unsupported locale value: ${currentValue}, defaulting to en-GB`)
}
} catch (err) {
console.error(`failed to get locale setting, using default en-GB:`, err)
}
try {
// Get name of module.
let localeModuleID = angularLocaleID;
if (localeModuleID == "en-US") {
localeModuleID = "en";
}
/* webpackInclude: /(en|en-GB)\.mjs$/ */
/* webpackChunkName: "./l10n-base/[request]"*/
await import(`../../node_modules/@angular/common/locales/${localeModuleID}.mjs`)
.then(locale => {
registerLocaleData(locale.default)
localeConfig.localeId = angularLocaleID;
localeConfig.nzLocale = (i18n as any)[nzLocaleID];
})
} catch (err) {
console.error(`failed to load locale module for ${angularLocaleID}:`, err)
}
}
}
const localeConfig = {
nzLocale: i18n.en_GB,
localeId: 'en-GB'
}
@NgModule({
declarations: [
AppComponent,
NotificationComponent,
SettingsComponent,
MonitorPageComponent,
SideDashComponent,
NavigationComponent,
PilotWidgetComponent,
NotificationListComponent,
PromptListComponent,
FuzzySearchPipe,
AppViewComponent,
QuickSettingInternetButtonComponent,
QuickSettingUseSPNButtonComponent,
QuickSettingSelectExitButtonComponent,
AppOverviewComponent,
PlaceholderComponent,
LoadingComponent,
ExternalLinkDirective,
ExitScreenComponent,
SupportPageComponent,
SupportFormComponent,
SecurityLockComponent,
SPNStatusComponent,
FeatureScoutComponent,
SPNLoginComponent,
SPNAccountDetailsComponent,
NetworkScoutComponent,
EditProfileDialog,
ProcessDetailsDialogComponent,
QsHistoryComponent,
DashboardPageComponent,
DashboardWidgetComponent,
FeatureCardComponent,
MergeProfileDialogComponent,
AppInsightsComponent,
SupportProgressDialogComponent
],
imports: [
BrowserModule,
CommonModule,
BrowserAnimationsModule,
FormsModule,
ReactiveFormsModule,
AppRoutingModule,
FontAwesomeModule,
OverlayModule,
PortalModule,
CdkTableModule,
DragDropModule,
HttpClientModule,
MarkdownModule.forRoot(),
ScrollingModule,
SfngAccordionModule,
TabModule,
SfngTipUpModule.forRoot(MyYamlFile, NotificationsService),
SfngTooltipModule,
ActionIndicatorModule,
SfngDialogModule,
OverlayStepperModule,
IntroModule,
SfngDropDownModule,
SfngSelectModule,
SfngMultiSwitchModule,
SfngMenuModule,
SfngFocusModule,
SfngToggleSwitchModule,
SfngPaginationModule,
SfngAppIconModule,
ExpertiseModule,
ConfigModule,
CountryFlagModule,
CountIndicatorModule,
NetqueryModule,
CommonPipesModule,
UiModule,
SPNModule,
PortmasterAPIModule.forRoot({
httpAPI: environment.httpAPI,
websocketAPI: environment.portAPI,
}),
],
bootstrap: [AppComponent],
providers: [
{
provide: APP_INITIALIZER, useFactory: loadAndSetLocaleInitializer, deps: [ConfigService], multi: true
},
{
provide: i18n.NZ_I18N, useFactory: () => {
console.log("nz-locale is set to", localeConfig.nzLocale)
return localeConfig.nzLocale
}
},
{
provide: LOCALE_ID, useFactory: () => {
console.log("locale-id is set to", localeConfig.localeId)
return localeConfig.localeId
}
},
{
provide: INTEGRATION_SERVICE,
useFactory: integrationServiceFactory
}
]
})
export class AppModule {
constructor(library: FaIconLibrary) {
library.addIconPacks(fas, far);
library.addIcons(faGithub)
}
}

View File

@@ -0,0 +1,41 @@
import { AppInfo, IntegrationService, ProcessInfo } from "./integration";
export class BrowserIntegrationService implements IntegrationService {
writeToClipboard(text: string): Promise<void> {
if (!!navigator.clipboard) {
return navigator.clipboard.writeText(text);
}
return Promise.reject(new Error(`Clipboard API not supported`))
}
openExternal(pathOrUrl: string): Promise<void> {
window.open(pathOrUrl, '_blank')
return Promise.resolve();
}
getInstallDir(): Promise<string> {
return Promise.reject('Not supported in browser')
}
getAppIcon(_: ProcessInfo): Promise<string> {
return Promise.reject('Not supported in browser')
}
getAppInfo(_: ProcessInfo): Promise<AppInfo> {
return Promise.reject('Not supported in browser')
}
exitApp(): Promise<void> {
window.close();
return Promise.resolve();
}
onExitRequest(cb: () => void): () => void {
// nothing to do, there
return () => { }
}
}

View File

@@ -0,0 +1,55 @@
import { BrowserIntegrationService } from "./browser";
import { AppInfo, ProcessInfo } from "./integration";
export class ElectronIntegrationService extends BrowserIntegrationService {
openExternal(pathOrUrl: string): Promise<void> {
if (!!window.app) {
return window.app.openExternal(pathOrUrl);
}
return Promise.reject('No electron API available')
}
getInstallDir(): Promise<string> {
if (!!window.app) {
return window.app.getInstallDir()
}
return Promise.reject('No electron API available')
}
getAppIcon(info: ProcessInfo): Promise<string> {
if (!!window.app) {
return window.app.getFileIcon(info.execPath)
}
return Promise.reject('No electron API available')
}
getAppInfo(_: ProcessInfo): Promise<AppInfo> {
return Promise.reject('Not supported in electron')
}
exitApp(): Promise<void> {
if (!!window.app) {
window.app.exitApp();
}
return Promise.resolve();
}
onExitRequest(cb: () => void): () => void {
let listener = (event: MessageEvent<any>) => {
if (event.data === 'on-app-close') {
cb();
}
}
window.addEventListener('message', listener);
return () => {
window.removeEventListener('message', listener)
}
}
}

View File

@@ -0,0 +1,22 @@
import { InjectionToken } from "@angular/core";
import { BrowserIntegrationService } from "./browser";
import { ElectronIntegrationService } from "./electron";
import { IntegrationService } from "./integration";
import { TauriIntegrationService } from "./taur-app";
export function integrationServiceFactory(): IntegrationService {
if ('__TAURI__' in window) {
console.log("[app] running under tauri")
return new TauriIntegrationService();
}
if ('app' in window) {
console.log("[app] running under electron")
return new ElectronIntegrationService();
}
console.log("[app] running in browser")
return new BrowserIntegrationService();
}
export const INTEGRATION_SERVICE = new InjectionToken<IntegrationService>('INTEGRATION_SERVICE');

View File

@@ -0,0 +1,2 @@
export * from './integration';
export * from './factory';

View File

@@ -0,0 +1,41 @@
export interface AppInfo {
app_name: string;
comment: string;
icon_dataurl: string;
icon_path: string;
}
export interface ProcessInfo {
execPath: string;
cmdline: string;
pid: number;
matchingPath: string;
}
export interface IntegrationService {
/** writeToClipboard copies text to the system clipboard */
writeToClipboard(text: string): Promise<void>;
/** openExternal opens a file or URL in an external window */
openExternal(pathOrUrl: string): Promise<void>;
/** Gets the path to the portmaster installation directory */
getInstallDir(): Promise<string>;
/** Load application information (currently linux only) */
getAppInfo(info: ProcessInfo): Promise<AppInfo>;
/** Loads the application icon as a dataurl */
getAppIcon(info: ProcessInfo): Promise<string>;
/** Closes the application, does not return */
exitApp(): Promise<void>;
/** Registers a listener for on-close requests. */
onExitRequest(cb: () => void): () => void;
}

View File

@@ -0,0 +1,216 @@
import { AppInfo, IntegrationService, ProcessInfo } from "./integration";
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { open } from '@tauri-apps/plugin-shell';
import { listen, once } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core'
import { getCurrent, Window } from '@tauri-apps/api/window';
// Returns a new uuidv4. If crypto.randomUUID is not available it fals back to
// using Math.random(). While this is not as random as it should be it's still
// enough for our use-case here (which is just to generate a random response-id).
function uuid(): string {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// This one is not really random and not RFC compliant but serves enough for fallback
// purposes if the UI is opened in a browser that does not yet support randomUUID
console.warn('Using browser with lacking support for crypto.randomUUID()');
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
function asyncInvoke<T>(method: string, args: object): Promise<T> {
return new Promise<T>((resolve, reject) => {
const eventId = uuid();
once<T & { error: string }>(eventId, (event) => {
if (typeof event.payload === 'object' && 'error' in event.payload) {
reject(event.payload);
return
};
resolve(event.payload);
})
invoke<string>(method, {
...args,
responseId: eventId,
}).catch((err: any) => {
console.error("tauri:invoke rejected: ", method, args, err);
reject(err)
});
})
}
export type ServiceManagerStatus = 'Running' | 'Stopped' | 'NotFound' | 'unsupported service manager' | 'unsupported operating system';
export class TauriIntegrationService implements IntegrationService {
private withPrompts = false;
constructor() {
this.shouldHandlePrompts()
.then(result => {
this.withPrompts = result;
});
// listen for the portmaster:show event that is emitted
// when tauri want's to tell us that we should make our
// window visible.
listen("portmaster:show", () => {
this.openApp();
})
}
writeToClipboard(text: string): Promise<void> {
return writeText(text);
}
openExternal(pathOrUrl: string): Promise<void> {
return open(pathOrUrl);
}
getInstallDir(): Promise<string> {
return Promise.reject("not yet supported in tauri")
}
getAppInfo(info: ProcessInfo): Promise<AppInfo> {
return asyncInvoke("plugin:portmaster|get_app_info", {
...info,
})
}
getAppIcon(info: ProcessInfo): Promise<string> {
return this.getAppInfo(info)
.then(info => info.icon_dataurl)
}
exitApp(): Promise<void> {
// we have two options here:
// - close(): close the native tauri window and release all resources of it.
// this has the disadvantage that if the user re-opens the window,
// it will take slightly longer because angular need to re-bootstrap
// the application.
//
// IMPORTANT: the angular application will automatically launch prompt
// windows via the tauri window interface. If we would call close(),
// those prompts wouldn't work anymore because the angular app would not
// be running in the background.
//
// - hide(): just set the window visibility to false. The advantage is that angular
// is still running and interacting with portmaster but it also means that
// we waste some system resources due to tauri window objects and the angular
// application.
getCurrent().hide()
return Promise.resolve();
}
// Tauri specific functions that are not defined in the IntegrationService interface.
// to use those methods you must check if integration instanceof TauriIntegrationService.
async shouldShow(): Promise<boolean> {
try {
const response = await invoke<string>("plugin:portmaster|should_show");
return response === "show";
} catch (err) {
console.error(err);
return true;
}
}
async shouldHandlePrompts(): Promise<boolean> {
try {
const response = await invoke<string>("plugin:portmaster|should_handle_prompts")
return response === "true"
} catch (err) {
console.error(err);
return false;
}
}
get_state(key: string): Promise<string> {
return invoke<string>("plugin:portmaster|get_state");
}
set_state(key: string, value: string): Promise<void> {
return invoke<void>("plugin:portmaster|set_state", {
key,
value
})
}
getServiceManagerStatus(): Promise<ServiceManagerStatus> {
return asyncInvoke("plugin:portmaster|get_service_manager_status", {})
}
startService(): Promise<any> {
return asyncInvoke("plugin:portmaster|start_service", {});
}
onExitRequest(cb: () => void): () => void {
let unlisten: () => void = () => { };
listen('exit-requested', () => {
cb();
}).then(cleanup => {
unlisten = cleanup;
})
return () => {
unlisten();
}
}
openApp() {
Window.getByLabel("splash")?.close();
const current = Window.getCurrent()
current.isVisible()
.then(visible => {
if (!visible) {
current.show();
current.setFocus();
}
});
}
closePrompt() {
Window.getByLabel("prompt")?.close();
}
openPrompt() {
if (!this.withPrompts) {
return;
}
if (Window.getByLabel("prompt")) {
return;
}
let promptWindow = new Window("prompt", {
alwaysOnTop: true,
decorations: false,
minimizable: false,
maximizable: false,
resizable: false,
title: 'Portmaster Prompt',
visible: false, // the prompt marks it self as visible.
skipTaskbar: true,
closable: false,
center: true,
width: 600,
height: 300,
// in src/main.ts we check the current location path
// and if it matches /prompt, we bootstrap the PromptEntryPointComponent
// instead of the AppComponent.
url: `http://${window.location.host}/prompt`,
} as any)
promptWindow.once("tauri://error", (err) => {
console.error(err);
});
}
}

View File

@@ -0,0 +1 @@
export * from './intro.module';

View File

@@ -0,0 +1,36 @@
import { OverlayModule } from "@angular/cdk/overlay";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { SfngDropDownModule, SfngTipUpModule, StepperConfig } from "@safing/ui";
import { ConfigModule } from "../shared/config";
import { Step1WelcomeComponent } from "./step-1-welcome";
import { Step2TrackersComponent } from "./step-2-trackers";
import { Step3DNSComponent } from "./step-3-dns";
import { Step4TipupsComponent } from "./step-4-tipups";
const steps = [
Step1WelcomeComponent,
Step2TrackersComponent,
Step3DNSComponent,
Step4TipupsComponent,
]
@NgModule({
imports: [
CommonModule,
OverlayModule,
FormsModule,
SfngDropDownModule,
ConfigModule,
SfngTipUpModule,
],
declarations: steps
})
export class IntroModule {
static Stepper: StepperConfig = {
steps: steps,
canAbort: (idx) => idx === 0,
}
}

View File

@@ -0,0 +1 @@
export * from './step-1-welcome';

View File

@@ -0,0 +1,14 @@
<h1>Portmaster Protects Your Privacy</h1>
<p>
Portmaster enhances your privacy with powerful defaults - no configuration needed! Of course you can customize
everything to your specific needs.
</p>
<!-- This card does not use the default button template -->
<ng-template #buttonTemplate>
<button class="self-center w-56 py-2 mb-6 rounded-full bg-blue hover:bg-blue hover:bg-opacity-75"
(click)="stepRef.next()">
Quick Setup
</button>
</ng-template>

View File

@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, Inject, TemplateRef, ViewChild } from "@angular/core";
import { Step, StepRef, STEP_REF } from "@safing/ui";
import { of } from "rxjs";
@Component({
templateUrl: './step-1-welcome.html',
styleUrls: ['../step.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Step1WelcomeComponent implements Step {
validChange = of(true)
readonly nextButtonLabel = 'Quick Setup';
@ViewChild('buttonTemplate', { static: true })
buttonTemplate!: TemplateRef<any>;
constructor(
@Inject(STEP_REF) public stepRef: StepRef<void>,
) { }
}

View File

@@ -0,0 +1 @@
export * from './step-2-trackers'

View File

@@ -0,0 +1,11 @@
<h1>Trackers Are Blocked System-Wide</h1>
<p>Portmaster automatically blocks ads, trackers and malware hosts on your whole device. Portmaster knows what to block
through trusted domain lists, which are also used by Ad-Blockers in browsers, etc. You can always customize this in
the settings.</p>
<sfng-dropdown label="Customize" class="w-full" maxHeight="300px" maxWidth="600px" offsetY="0">
<app-generic-setting class="h-full" [setting]="setting" [attr.id]="setting?.Key" (save)="saveSetting($event)"
enableActiveBorder="false" showHeader="false">
</app-generic-setting>
</sfng-dropdown>

View File

@@ -0,0 +1,48 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ConfigService, Setting } from "@safing/portmaster-api";
import { Step } from "@safing/ui";
import { of } from "rxjs";
import { mergeMap } from "rxjs/operators";
import { SaveSettingEvent } from "src/app/shared/config/generic-setting";
@Component({
templateUrl: './step-2-trackers.html',
styleUrls: ['../step.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Step2TrackersComponent implements Step, OnInit {
private destroyRef = inject(DestroyRef);
validChange = of(true)
setting: Setting | null = null;
constructor(
public configService: ConfigService,
public readonly elementRef: ElementRef,
private cdr: ChangeDetectorRef,
) { }
ngOnInit(): void {
this.configService.get('filter/lists')
.pipe(
mergeMap(setting => {
this.setting = setting;
return this.configService.watch(setting.Key)
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(value => {
this.setting!.Value = value;
this.cdr.markForCheck();
});
}
saveSetting(event: SaveSettingEvent) {
this.configService.save(event.key, event.value)
.subscribe()
}
}

View File

@@ -0,0 +1 @@
export * from './step-3-dns'

View File

@@ -0,0 +1,17 @@
<h1>Secure DNS for All Connections</h1>
<p>Portmaster automatically encrypts all your DNS queries to safeguard them from prying eyes. Portmaster sets a default
provider, but you can always switch to a custom DNS-over-TLS provider in the global settings.</p>
<sfng-dropdown label="Customize" class="w-full" maxHeight="300px" maxWidth="600px" offsetY="0">
<div class="flex flex-wrap items-center justify-center w-full gap-6 px-8 py-8">
<button *ngFor="let button of quickSettings" [style.minWidth]="'6rem'" (click)="applyQuickSetting(button)"
[disabled]="isCustomValue" [class.bg-blue]="button.active" class="px-4 py-3">
{{ button.Name }}
</button>
<button class="w-24 px-4 py-3 bg-blue" *ngIf="isCustomValue">
Custom
</button>
</div>
</sfng-dropdown>

View File

@@ -0,0 +1,106 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ConfigService, QuickSetting, Setting, applyQuickSetting } from "@safing/portmaster-api";
import { Step } from "@safing/ui";
import { of } from "rxjs";
import { mergeMap } from "rxjs/operators";
import { SaveSettingEvent } from "src/app/shared/config/generic-setting";
interface QuickSettingModel extends QuickSetting<any> {
active: boolean;
}
@Component({
templateUrl: './step-3-dns.html',
styleUrls: ['../step.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Step3DNSComponent implements Step, OnInit {
private destroyRef = inject(DestroyRef);
validChange = of(true)
setting: Setting | null = null;
quickSettings: QuickSettingModel[] = [];
isCustomValue = false;
constructor(
public configService: ConfigService,
public readonly elementRef: ElementRef,
private cdr: ChangeDetectorRef,
) { }
private getQuickSettings(): QuickSettingModel[] {
if (!this.setting) {
return [];
}
let val = this.setting.Annotations["safing/portbase:ui:quick-setting"];
if (val === undefined) {
return [];
}
if (!Array.isArray(val)) {
return [{
...val,
active: false,
}]
}
return val.map(v => ({
...v,
active: false,
}))
}
ngOnInit(): void {
this.configService.get('dns/nameservers')
.pipe(
mergeMap(setting => {
this.setting = setting;
this.quickSettings = this.getQuickSettings();
return this.configService.watch(setting.Key)
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(value => {
this.setting!.Value = value;
let hasActive = false;
this.isCustomValue = false;
this.quickSettings.forEach(setting => {
if (this.setting?.Value !== undefined && JSON.stringify(this.setting.Value) === JSON.stringify(setting.Value)) {
setting.active = true;
hasActive = true;
} else {
setting.active = false;
}
});
if (!hasActive) {
if (this.setting?.Value !== undefined && JSON.stringify(this.setting!.Value) !== JSON.stringify(this.setting!.DefaultValue)) {
this.isCustomValue = true;
} else if (this.quickSettings.length > 0) {
this.quickSettings[0].active = true;
}
}
this.cdr.markForCheck();
});
}
saveSetting(event: SaveSettingEvent) {
this.configService.save(event.key, event.value)
.subscribe()
}
applyQuickSetting(action: QuickSetting<any>) {
const newValue = applyQuickSetting(
this.setting!.Value || this.setting!.DefaultValue,
action,
)
this.configService.save(this.setting!.Key, newValue)
.subscribe();
}
}

View File

@@ -0,0 +1 @@
export * from './step-4-tipups'

View File

@@ -0,0 +1,11 @@
<h1>Learn More as You Explore</h1>
<p>Portmaster has a lot more to offer. When you decide to dive deeper you can always click on an information icon to
learn more about a certain feature. Look out for those!</p>
<div class="flex flex-col items-center justify-center gap-1 p-8 text-xxs">
<span class="text-tertiary">Click Me!</span>
<div class="flex items-center justify-center px-4 py-4 bg-gray-400 rounded">
<sfng-tipup class="transform scale-125" key="introTipup"></sfng-tipup>
</div>
</div>

View File

@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { Step } from "@safing/ui";
import { of } from "rxjs";
@Component({
templateUrl: './step-4-tipups.html',
styleUrls: ['../step.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Step4TipupsComponent implements Step {
validChange = of(true)
}

View File

@@ -0,0 +1,11 @@
:host {
@apply flex flex-col items-center justify-center;
}
h1 {
@apply text-primary text-2xl font-medium capitalize text-center py-5;
}
p {
@apply text-tertiary text-sm font-medium text-center;
}

View File

@@ -0,0 +1,230 @@
<div class="flex flex-col items-center gap-1">
<div class="relative w-16 h-16">
<app-security-lock [@fadeIn] [@fadeOut] mode="small" class="absolute w-16 h-16" routerLink="dashboard"
*ngIf="sideDashStatus === 'collapsed'">
</app-security-lock>
<div class="absolute flex flex-col items-center justify-center w-16 h-16 outline-none" routerLink="dashboard"
[@fadeIn] [@fadeOut] *ngIf="sideDashStatus === 'expanded'">
<div class="relative">
<svg [class.connected]="(connected$ | async)" data-name="Layer 1" viewBox="0 0 128 128"
class="spin reverse logo-image">
<g data-name="Main" fill-rule="evenodd">
<path shape-rendering="geometricPrecision" fill="#fff" class="inner"
d="M176.11 36.73l-5-8.61a41.53 41.53 0 00-14.73 57.22l8.55-5.12a31.58 31.58 0 0111.19-43.49z"
transform="translate(-127.99 .01)" style="isolation:isolate" opacity=".8"></path>
<path shape-rendering="geometricPrecision" fill="#fff" class="inner"
d="M222.36 72.63a31.55 31.55 0 01-45 19.35l-4.62 8.84a41.54 41.54 0 0059.17-25.46z"
transform="translate(-127.99 .01)" style="isolation:isolate" opacity=".8"></path>
</g>
</svg>
<svg [class.connected]="(connected$ | async)" data-name="Layer 1" viewBox="0 0 128 128" class="spin logo-image">
<g data-name="Main" fill-rule="evenodd">
<path shape-rendering="geometricPrecision" fill="#fff" class="inner reverse"
d="M197 83a19.66 19.66 0 01-19.25-32.57l-4.5-4.27A25.87 25.87 0 00198.59 89z"
transform="translate(-127.99 .01)" style="isolation:isolate" opacity=".6"></path>
</g>
</svg>
<svg [class.connected]="(connected$ | async)" data-name="Layer 1" viewBox="0 0 128 128" class="logo-image">
<g data-name="Main" fill-rule="evenodd">
<path shape-rendering="geometricPrecision" fill="#fff"
d="M192 112.64A48.64 48.64 0 11240.64 64 48.64 48.64 0 01192 112.64zM256 64a64 64 0 10-64 64 64 64 0 0064-64z"
transform="translate(-127.99 .1)"></path>
</g>
</svg>
</div>
</div>
</div>
<div class="flex justify-center">
<sfng-tipup key="intro"></sfng-tipup>
</div>
<div class="nav-list">
<div class="relative link" (click)="toggleSideDash($event)">
<svg [class.-rotate-180]="sideDashStatus === 'expanded'" [class.bg-gray-400]="sideDashStatus === 'collapsed'"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
class="w-5 h-5 transition-all duration-200 rounded">
<path fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd" />
</svg>
</div>
</div>
<div class="pt-1 border-t border-gray-400 nav-list">
<!-- The notification drop-down -->
<div sfngTipUpTrigger="navNotifications" sfngTipUpPassive class="relative mt-3" (click)="toggleSideDash($event)"
*ngIf="sideDashStatus !== 'expanded'">
<svg class="w-4 h-4 {{ hasNewNotifications ? notificationColor : 'text-tertiary' }}" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</div>
<!-- The prompt list drop-down -->
<div class="relative link" cdkOverlayOrigin #promptOrigin="cdkOverlayOrigin"
*ngIf="hasNewPrompts || globalPromptingEnabled" (click)="promptDropDown.toggle(promptOrigin)"
[class.active]="promptDropDown.isOpen">
<span *ngIf="hasNewPrompts" class="absolute w-1.5 h-1.5 bg-yellow-300 rounded-full top-1.5 right-1.5"></span>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<sfng-dropdown [positions]="dropDownPositions" externalTrigger="true" #promptDropDown offsetY="0" offsetX="10"
overlayClass="rounded-t">
<app-prompt-list></app-prompt-list>
</sfng-dropdown>
</div>
</div>
<div class="nav-list">
<!-- Network Activity -->
<div sfng-tooltip="Network Activity" sfngTooltipDelay="1000" snfgTooltipPosition="right" routerLinkActive="active"
routerLink="monitor" class="link" sfngTipUpTrigger="navMonitor" sfngTipUpPassive>
<svg viewBox="0 0 24 24" class="monitor">
<g fill="none">
<path shape-rendering="geometricPrecision" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.464 8.464c-1.953 1.953-1.953 5.118 0 7.071 1.953 1.953 5.118 1.953 7.071 0 1.953-1.953 1.953-5.119 0-7.071C14.559 7.488 13.28 7 12 7" />
<path shape-rendering="geometricPrecision" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5.636 5.636c-3.515 3.515-3.515 9.213 0 12.728 3.515 3.515 9.213 3.515 12.728 0 3.515-3.515 3.515-9.213 0-12.728-2.627-2.627-6.474-3.289-9.717-1.989M5.64 5.64L12 12" />
</g>
</svg>
</div>
<!-- App View -->
<div sfng-tooltip="Apps and Profiles" sfngTooltipDelay="1000" snfgTooltipPosition="right" routerLinkActive="active"
routerLink="app" class="link" sfngTipUpTrigger="navApps" sfngTipUpPassive>
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 24 24" class="app" fill="none">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" fill="currentColor"
d="M19 21h-3a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2Z" />
<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9h-3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2ZM5 3h3a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2ZM5 15h3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2Z" />
</svg>
</div>
<!-- SPN -->
<div sfng-tooltip="Safing Privacy Network" sfngTooltipDelay="1000" snfgTooltipPosition="right"
routerLinkActive="active" routerLink="spn" class="link" sfngTipUpTrigger="navMap" sfngTipUpPassive>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="spn" stroke="currentColor">
<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path
d="M6.488 15.581c.782.781.782 2.048 0 2.829-.782.781-2.049.781-2.83 0-.782-.781-.782-2.048 0-2.829.781-.781 2.048-.781 2.83 0M13.415 3.586c.782.781.782 2.048 0 2.829-.782.781-2.049.781-2.83 0-.782-.781-.782-2.048 0-2.829.781-.781 2.049-.781 2.83 0M20.343 15.58c.782.781.782 2.048 0 2.829-.782.781-2.049.781-2.83 0-.782-.781-.782-2.048 0-2.829.781-.781 2.048-.781 2.83 0" />
<path
d="M17.721 18.581C16.269 20.071 14.246 21 12 21c-1.146 0-2.231-.246-3.215-.68M4.293 15.152c-.56-1.999-.352-4.21.769-6.151.574-.995 1.334-1.814 2.205-2.449M13.975 5.254c2.017.512 3.834 1.799 4.957 3.743.569.985.899 2.041 1.018 3.103" />
</g>
</svg>
</div>
<!-- Global Settings -->
<div sfng-tooltip="Global Settings" sfngTooltipDelay="1000" snfgTooltipPosition="right" routerLinkActive="active"
routerLink="settings" class="link" sfngTipUpTrigger="navSettings" sfngTipUpPassive>
<svg viewBox="0 0 24 24" class="settings">
<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path shape-rendering="geometricPrecision"
d="M13.7678 10.2322c.976311.976311.976311 2.55922 0 3.53553-.976311.976311-2.55922.976311-3.53553 0-.976311-.976311-.976311-2.55922 0-3.53553.976311-.976311 2.55922-.976311 3.53553 0" />
<path shape-rendering="geometricPrecision"
d="M14.849 4.12l.583.194c.534.178.895.678.895 1.241v.837c0 .712.568 1.293 1.28 1.308l.838.018c.485.01.925.289 1.142.723l.275.55c.252.504.153 1.112-.245 1.51l-.592.592c-.503.503-.512 1.316-.02 1.83l.58.606c.336.351.45.858.296 1.319l-.194.583c-.178.534-.678.895-1.241.895h-.837c-.712 0-1.293.568-1.308 1.28l-.018.838c-.01.485-.289.925-.723 1.142l-.55.275c-.504.252-1.112.153-1.51-.245l-.592-.592c-.503-.503-1.316-.512-1.83-.02l-.606.58c-.351.336-.858.45-1.319.296l-.583-.194c-.534-.178-.895-.678-.895-1.241v-.837c0-.712-.568-1.293-1.28-1.308l-.838-.018c-.485-.01-.925-.289-1.142-.723l-.275-.55c-.252-.504-.153-1.112.245-1.51l.592-.592c.503-.503.512-1.316.02-1.83l-.58-.606c-.337-.352-.451-.86-.297-1.32l.194-.583c.178-.534.678-.895 1.241-.895h.837c.712 0 1.293-.568 1.308-1.28l.018-.838c.012-.485.29-.925.724-1.142l.55-.275c.504-.252 1.112-.153 1.51.245l.592.592c.503.503 1.316.512 1.83.02l.606-.58c.351-.335.859-.449 1.319-.295z" />
</g>
</svg>
</div>
<div class="w-full border-t border-gray-400"></div>
<div sfng-tooltip="Get Help" sfngTooltipDelay="1000" snfgTooltipPosition="right" routerLink="support"
routerLinkActive="active" class="link" sfngTipUpTrigger="navSupport" sfngTipUpPassive>
<svg viewBox="0 0 24 24" class="help">
<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path shape-rendering="geometricPrecision"
d="M12 21v0c-4.971 0-9-4.029-9-9v0c0-4.971 4.029-9 9-9v0c4.971 0 9 4.029 9 9v0c0 4.971-4.029 9-9 9z" />
<path shape-rendering="geometricPrecision"
d="M12 13.25V13c0-.817.505-1.26 1.011-1.6.494-.333.989-.767.989-1.567 0-1.105-.895-2-2-2s-2 .895-2 2M11.999 16c-.138 0-.25.112-.249.25 0 .138.112.25.25.25s.25-.112.25-.25-.112-.25-.251-.25" />
</g>
</svg>
</div>
</div>
<div class="nav-lower-list">
<div class="relative link" sfngTipUpTrigger="navTools" sfngTipUpPassive tooltip="Version and Tools"
sfngTooltipDelay="1000" snfgTooltipPosition="right" (click)="settingsMenu.dropdown.toggle(settingsMenuTrigger)"
cdkOverlayOrigin #settingsMenuTrigger="cdkOverlayOrigin" [class.active]="settingsMenu.dropdown.isOpen">
<span *ngIf="versions?.Channel !== 'stable'"
class="absolute w-1.5 h-1.5 bg-yellow-300 rounded-full top-1.5 right-1.5"></span>
<svg version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="help"
xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke-linecap="round" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linejoin="round">
<path d="M11.835,15l5,5c0.828,0.828 2.172,0.828 3,0v0c0.828,-0.828 0.828,-2.172 0,-3l-5,-5"></path>
<path
d="M20.916,5.847c0.024,0.023 0.042,0.053 0.051,0.085c0.47,1.567 0.106,3.33 -1.132,4.568c-1.251,1.251 -3.038,1.609 -4.617,1.117l-8.347,8.347c-0.813,0.813 -2.139,0.874 -2.98,0.09c-0.884,-0.823 -0.902,-2.207 -0.056,-3.054l8.383,-8.383c-0.492,-1.579 -0.134,-3.366 1.117,-4.617c1.238,-1.238 3.001,-1.602 4.568,-1.132c0.032,0.01 0.062,0.027 0.085,0.051l0.162,0.162c0.078,0.078 0.078,0.205 0,0.283l-2.635,2.636l2.32,2.32l2.636,-2.636c0.078,-0.078 0.205,-0.078 0.283,0l0.162,0.163Z">
</path>
<path
d="M2.933,4.293l0.674,2.023c0.136,0.409 0.518,0.684 0.949,0.684h2.279v-2.279c0,-0.43 -0.275,-0.813 -0.684,-0.949l-2.023,-0.674c-0.18,-0.06 -0.378,-0.013 -0.512,0.121l-0.562,0.562c-0.134,0.134 -0.181,0.332 -0.121,0.512Z">
</path>
<path d="M6.84,7l3.5,3.5"></path>
</g>
</svg>
</div>
<app-menu #settingsMenu offsetY="0" offsetX="10" overlayClass="rounded-t">
<div class="flex flex-col p-4 text-xxs">
<span class="text-secondary">
Version: <span class="text-primary">{{ versions?.Core?.Version }} </span>
</span>
<span class="text-secondary">
Release Channel:
<span class="uppercase text-primary"
[class.text-yellow-300]="versions?.Channel !== 'stable'">{{ versions?.Channel }}</span>
</span>
</div>
<app-menu-item (click)="downloadUpdates($event)">Check for Updates</app-menu-item>
<app-menu-item (click)="openChangeLog()">View Changelog</app-menu-item>
<app-menu-item (click)="reloadUI($event)">Reload UI</app-menu-item>
<app-menu-item *appExpertiseLevel="'developer'" (click)="showIntro()">
Show Intro Screen
</app-menu-item>
<app-menu-item (click)="reinitSPN($event)">Re-Initialize SPN</app-menu-item>
<app-menu-item (click)="logoutCompletely($event)">Logout Completely</app-menu-item>
<app-menu-item (click)="resetBroadcastState()">Reset Broadcast State</app-menu-item>
<app-menu-item (click)="clearDNSCache($event)">Clear DNS Cache</app-menu-item>
<app-menu-item (click)="openDataDir($event)">Open Data Directory</app-menu-item>
<app-menu-item (click)="copyDebugInfo($event)">Copy Debug Info</app-menu-item>
<app-menu-item (click)="cleanupHistory($event)">Cleanup Network History</app-menu-item>
</app-menu>
<!-- Power Menu -->
<div sfngTipUpTrigger="navPower" sfngTipUpPassive tooltip="Shutdown and Restart" sfngTooltipDelay="1000"
snfgTooltipPosition="right" class="link" (click)="powerMenu.dropdown.toggle(powerMenuTrigger)" cdkOverlayOrigin
#powerMenuTrigger="cdkOverlayOrigin" [class.active]="powerMenu.dropdown.isOpen">
<svg version="1.1" viewBox="0 0 24 24" class="help" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<g fill="none">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M18.364,5.63604c3.51472,3.51472 3.51472,9.2132 0,12.7279c-3.51472,3.51472 -9.2132,3.51472 -12.7279,0c-3.51472,-3.51472 -3.51472,-9.2132 -1.77636e-15,-12.7279c3.51472,-3.51472 9.2132,-3.51472 12.7279,-1.77636e-15">
</path>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12,7v5">
</path>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M15.534,8.466c1.952,1.952 1.952,5.117 0,7.069c-1.952,1.952 -5.117,1.952 -7.069,0c-1.952,-1.952 -1.952,-5.117 0,-7.069">
</path>
</g>
</svg>
</div>
<app-menu #powerMenu offsetY="0" offsetX="10" overlayClass="rounded-t">
<app-menu-item (click)="shutdown($event)">Shutdown</app-menu-item>
<app-menu-item (click)="restart($event)">Restart</app-menu-item>
</app-menu>
</div>

View File

@@ -0,0 +1,98 @@
:host {
height: 100vh;
top: 0px;
position: sticky;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
user-select: none;
.logo-image {
@apply w-6 -top-3 -left-3 absolute;
position: absolute;
}
svg {
&:not(.connected) {
animation-timing-function: cubic-bezier(0.445, 0.05, 0.55, 0.95);
path.inner {
fill: theme('colors.info.red');
}
}
}
div.nav-list {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
}
div.nav-lower-list {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding-bottom: 1.5rem;
}
div.link {
@apply my-2;
width: 2rem;
height: 2rem;
border-radius: 10px;
display: flex;
justify-content: space-around;
align-items: center;
cursor: pointer;
& {
outline: none;
svg,
fa-icon {
opacity: .5;
}
}
&:target,
&.active {
background-color: #2c2c2c;
svg,
fa-icon {
opacity: 1;
transform: scale(1.08);
}
}
&:hover {
svg,
fa-icon {
opacity: 1;
}
}
svg,
fa-icon {
&.dash,
&.spn,
&.monitor,
&.app,
&.help,
&.settings {
@apply text-white;
width: 1.1rem;
position: relative;
stroke: currentColor;
}
}
}
}

View File

@@ -0,0 +1,298 @@
import { INTEGRATION_SERVICE, IntegrationService } from 'src/app/integration';
import { ConnectedPosition } from '@angular/cdk/overlay';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Inject, OnInit, Output, inject } from '@angular/core';
import { ConfigService, DebugAPI, PortapiService, SPNService, StringSetting } from '@safing/portmaster-api';
import { tap } from 'rxjs/operators';
import { AppComponent } from 'src/app/app.component';
import { NotificationType, NotificationsService, StatusService, VersionStatus } from 'src/app/services';
import { ActionIndicatorService } from 'src/app/shared/action-indicator';
import { fadeInAnimation, fadeOutAnimation } from 'src/app/shared/animations';
import { ExitService } from 'src/app/shared/exit-screen';
import { TauriIntegrationService } from 'src/app/integration/taur-app';
@Component({
selector: 'app-navigation',
templateUrl: './navigation.html',
styleUrls: ['./navigation.scss'],
exportAs: 'navigation',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
fadeInAnimation,
fadeOutAnimation,
]
})
export class NavigationComponent implements OnInit {
private readonly integration = inject(INTEGRATION_SERVICE);
/** Emits the current portapi connection state on changes. */
readonly connected$ = this.portapi.connected$;
/** @private The available and selected resource versions. */
versions: VersionStatus | null = null;
/** Whether or not we have new, unseen notifications */
hasNewNotifications = false;
/** The color to use for the notifcation-available hint (dot) */
notificationColor: string = 'text-green-300';
/** Whether or not we have new, unseen prompts */
hasNewPrompts = false;
/** Whether or not prompting is globally enabled. */
globalPromptingEnabled = false;
@Output()
sideDashChange = new EventEmitter<'collapsed' | 'expanded' | 'force-overlay'>();
/** Whether or not the side dash should be expanded or collapsed */
sideDashStatus: 'collapsed' | 'expanded' = 'expanded';
constructor(
private portapi: PortapiService,
private exitService: ExitService,
private statusService: StatusService,
private configService: ConfigService,
private appComponent: AppComponent,
private debugAPI: DebugAPI,
private actionIndicator: ActionIndicatorService,
private notificationService: NotificationsService,
private spnService: SPNService,
private cdr: ChangeDetectorRef
) { }
dropDownPositions: ConnectedPosition[] = [
{
originX: 'end',
originY: 'top',
overlayX: 'start',
overlayY: 'top'
}
]
ngOnInit() {
const mql = window.matchMedia('(max-width: 1200px)');
if (mql.matches) {
this.sideDashStatus = 'collapsed';
this.sideDashChange.next(this.sideDashStatus);
}
mql.addEventListener('change', () => {
if (mql.matches) {
this.sideDashStatus = 'collapsed';
} else {
this.sideDashStatus = 'expanded';
}
this.sideDashChange.next(this.sideDashStatus);
})
this.statusService.getVersions()
.subscribe(versions => {
this.versions = versions;
this.cdr.markForCheck();
});
this.configService.watch<StringSetting>('filter/defaultAction')
.subscribe(defaultAction => {
this.globalPromptingEnabled = defaultAction === 'ask';
this.cdr.markForCheck();
})
this.notificationService.new$
.subscribe(notif => {
if (notif.some(n => n.Type === NotificationType.Prompt && n.EventID.startsWith("filter:prompt"))) {
this.hasNewPrompts = true;
if (this.integration instanceof TauriIntegrationService) {
this.integration.openPrompt();
}
} else {
this.hasNewPrompts = false;
if (this.integration instanceof TauriIntegrationService) {
this.integration.closePrompt();
}
}
if (notif.some(n => !n.EventID.startsWith("filter:prompt"))) {
this.hasNewNotifications = true;
} else {
this.hasNewNotifications = false;
}
if (notif.some(n => n.Type === NotificationType.Error)) {
this.notificationColor = 'text-red-300';
} else if (notif.some(n => n.Type === NotificationType.Warning)) {
this.notificationColor = 'text-yellow-300';
} else {
this.notificationColor = 'text-green-300';
}
this.cdr.markForCheck();
})
}
toggleSideDash(event: MouseEvent) {
let notify: 'expanded' | 'collapsed' | 'force-overlay' = this.sideDashStatus;
if (this.sideDashStatus === 'collapsed') {
this.sideDashStatus = 'expanded';
notify = 'expanded';
if (event.shiftKey) {
notify = 'force-overlay'
}
} else {
this.sideDashStatus = 'collapsed';
notify = 'collapsed'
}
this.sideDashChange.next(notify);
}
/**
* @private
* Injects a ui/reload event and performs a complete
* reload of the window once the portmaster re-opened the
* UI bundle.
*/
reloadUI(_: Event) {
this.portapi.reloadUI()
.pipe(
tap(() => {
setTimeout(() => window.location.reload(), 1000)
})
)
.subscribe(this.actionIndicator.httpObserver(
'Reloading UI ...',
'Failed to Reload UI',
))
}
/** Re-initialize the SPN */
reinitSPN(_: Event) {
this.portapi.reinitSPN()
.subscribe(this.actionIndicator.httpObserver(
'Re-initialized SPN',
'Failed to re-initialize the SPN'
))
}
/** Logs the user out of the SPN completely by purgin the user profile from the local storage */
logoutCompletely(_: Event) {
this.spnService.logout(true)
.subscribe(this.actionIndicator.httpObserver(
'Logout',
'You have been logged out of the SPN completely.'
))
}
/**
* @private
* Clear the DNS name cache.
*/
clearDNSCache(_: Event) {
this.portapi.clearDNSCache()
.subscribe(this.actionIndicator.httpObserver(
'DNS Cache Cleared',
'Failed to Clear DNS Cache.',
))
}
cleanupHistory(_: Event) {
this.portapi.cleanupHistory()
.subscribe(this.actionIndicator.httpObserver(
'Network History Cleaned Up',
'Failed to Cleanup Network History.'
))
}
/**
* @private
* Trigger downloading of updates
*
* @param event - The mouse event
*/
downloadUpdates(event: Event) {
this.portapi.checkForUpdates()
.subscribe(this.actionIndicator.httpObserver(
'Downloading Updates ...',
'Failed to Check for Updates',
))
}
/**
* @private
* Trigger a shutdown of the portmaster-core service
*/
shutdown(_: Event) {
this.exitService.shutdownPortmaster();
}
/**
* @private
* Trigger a restart of the portmaster-core service. Requires
* that portmaster has been started with a service-wrapper.
*
* @param event The mouse event
*/
restart(event: Event) {
// prevent default and stop-propagation to avoid
// expanding the accordion body.
event.preventDefault();
event.stopPropagation();
this.portapi.restartPortmaster()
.subscribe(this.actionIndicator.httpObserver(
'Restarting ...',
'Failed to Restart',
))
}
/**
* @private
* Opens the data-directory of the portmaster installation.
* Requires the application to run inside electron.
*/
async openDataDir(event: Event) {
const dir = await this.integration.getInstallDir()
await this.integration.openExternal(dir);
}
openChangeLog() {
const url = "https://github.com/safing/portmaster/releases";
this.integration.openExternal(url);
}
showIntro() {
this.appComponent.showIntro()
}
resetBroadcastState() {
this.portapi.resetBroadcastState()
.subscribe(this.actionIndicator.httpObserver(
'Broadcast State Cleared',
'Failed to Reset Broadcast State.',
))
}
copyDebugInfo(event: Event) {
// prevent default and stop-propagation to avoid
// expanding the accordion body.
event.preventDefault();
event.stopPropagation();
this.debugAPI.getCoreDebugInfo()
.subscribe(
async info => {
await this.integration.writeToClipboard(info);
},
err => {
console.error(err);
this.actionIndicator.error('Failed loading debug data', err);
}
)
}
}

View File

@@ -0,0 +1,10 @@
<div sfngTipUpTrigger="navShield" sfngTipUpPassive class="relative flex flex-row w-full gap-2 px-2 pb-4 justify-evenly">
<app-status-pilot class="block w-32"></app-status-pilot>
</div>
<app-feature-scout></app-feature-scout>
<app-notification-list></app-notification-list>
<app-spn-login *ngIf="spnLoginRequired"></app-spn-login>
<app-network-scout *ngIf="!spnLoginRequired" class="flex-grow overflow-auto"></app-network-scout>

View File

@@ -0,0 +1,11 @@
:host {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
overflow: hidden;
overflow-y: hidden;
width: 419px;
@apply pt-4;
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-side-dash',
templateUrl: './side-dash.html',
styleUrls: ['./side-dash.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SideDashComponent {
/** Whether or not a SPN account login is required */
spnLoginRequired = false;
}

View File

@@ -0,0 +1,27 @@
{
"name": "portmaster",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "portmaster",
"devDependencies": {
"@types/node": "^17.0.31"
}
},
"node_modules/@types/node": {
"version": "17.0.31",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.31.tgz",
"integrity": "sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==",
"dev": true
}
},
"dependencies": {
"@types/node": {
"version": "17.0.31",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.31.tgz",
"integrity": "sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==",
"dev": true
}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "portmaster",
"private": true,
"description_1": "This is a special package.json file that is not used by package managers.",
"description_2": "It is used to tell the tools and bundlers whether the code under this directory is free of code with non-local side-effect. Any code that does have non-local side-effects can't be well optimized (tree-shaken) and will result in unnecessary increased payload size.",
"description_3": "It should be safe to set this option to 'false' for new applications, but existing code bases could be broken when built with the production config if the application code does contain non-local side-effects that the application depends on.",
"description_4": "To learn more about this file see: https://angular.io/config/app-package-json.",
"sideEffects": false,
"devDependencies": {
"@types/node": "^17.0.31"
}
}

View File

@@ -0,0 +1,13 @@
<div class="grid grid-cols-2 gap-2">
<app-dashboard-widget label="Connections" style="min-height: 400px;">
<sfng-netquery-line-chart [data]="connectionChart"></sfng-netquery-line-chart>
</app-dashboard-widget>
<app-dashboard-widget label="Data Usage" beta="true" style="min-height: 400px;">
<sfng-netquery-line-chart [config]="bwChartConfig" [data]="bandwidthChart"></sfng-netquery-line-chart>
</app-dashboard-widget>
<app-dashboard-widget label="Countries" beta="true" style="min-height: 400px">
<sfng-netquery-circular-bar-chart class="block w-full h-full" [data]="countryData" [config]="countryBarConfig"></sfng-netquery-circular-bar-chart>
</app-dashboard-widget>
</div>

View File

@@ -0,0 +1,96 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Input, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AppProfile, BandwidthChartResult, ChartResult, Netquery } from '@safing/portmaster-api';
import { repeat } from 'rxjs';
import { CircularBarChartConfig, splitQueryResult } from 'src/app/shared/netquery/circular-bar-chart/circular-bar-chart.component';
import { DefaultBandwidthChartConfig } from 'src/app/shared/netquery/line-chart/line-chart';
interface CountryBarData {
series: 'country';
value: number;
country: string;
}
@Component({
selector: 'app-app-insights',
templateUrl: './app-insights.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppInsightsComponent implements OnInit {
private readonly netquery = inject(Netquery);
private readonly destroyRef = inject(DestroyRef);
private readonly cdr = inject(ChangeDetectorRef);
@Input()
profile!: AppProfile;
connectionChart: ChartResult[] = [];
bandwidthChart: BandwidthChartResult<any>[] = [];
bwChartConfig = DefaultBandwidthChartConfig;
countryData: CountryBarData[] = [];
readonly countryBarConfig: CircularBarChartConfig<CountryBarData> = {
stack: 'country',
seriesKey: 'series',
value: 'value',
ticks: 3,
colorAsClass: true,
series: {
'count': {
color: 'text-green-300 text-opacity-50',
},
},
}
ngOnInit() {
const key = `${this.profile.Source}/${this.profile.ID}`
this.netquery.batch({
countryData: {
select: [
'country',
{ $count: { field: '*', as: 'count' } },
],
query: {
internal: { $eq: false },
country: { $ne: '' }
},
groupBy: ['country']
}
})
.pipe(
repeat({ delay: 10000 }),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(result => {
this.countryData = splitQueryResult(result.countryData, ['count']) as CountryBarData[];
console.log(this.countryData)
this.cdr.markForCheck();
})
this.netquery.activeConnectionChart({ profile: key })
.pipe(
repeat({ delay: 10000 }),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(data => {
this.connectionChart = data;
this.cdr.markForCheck();
})
this.netquery.bandwidthChart({ profile: key }, undefined, 60)
.pipe(
repeat({ delay: 10000 }),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(data => {
this.bandwidthChart = data;
this.cdr.markForCheck();
})
}
}

View File

@@ -0,0 +1,425 @@
<ng-container *ngIf="!showOverview && !!appProfile">
<!-- Header -->
<div class="flex justify-between items-center p-4 px-12 text-xxs">
<!-- Breadcrumbs -->
<div class="flex items-center">
<a class="text-secondary hover:text-primary" [routerLink]="['/app/overview']">Apps</a>
<svg viewBox="0 0 24 24" class="inline-block w-4 h-4 text-secondary">
<g fill="none" class="inner" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.2" d="M10 16l4-4-4-4" />
</g>
</svg>
<span class="text-primary">{{ appProfile.Name }}</span>
</div>
<!-- Expertise level switch -->
<app-expertise></app-expertise>
</div>
<!-- Application Header -->
<div class="relative px-12 transition-all duration-200" [class.p-4]="!collapseHeader">
<div class="flex relative z-10 flex-row items-center w-full" [class.py-8]="!collapseHeader">
<!-- Application metadata -->
<div class="flex flex-col flex-grow items-start space-y-5">
<!-- App Name & Icon -->
<h1 class="flex flex-row gap-2 items-center mb-0 text-2xl whitespace-nowrap text-primary">
<app-icon [profile]="appProfile" style="--app-icon-size: 3rem"></app-icon>
<span>{{appProfile!.Name}}</span>
</h1>
<!-- App Metadata -->
<div class="text-tertiary text-xxs" *ngIf="!collapseHeader" [@fadeIn] [@fadeOut]>
<div class="space-x-2" *ngIf="!!applicationDirectory">
<span>Path:</span>
<span class="text-opacity-75 text-primary">
{{ applicationDirectory }}
</span>
</div>
<div class="space-x-2" *ngIf="!!binaryName">
<span>Binary:</span>
<span class="text-opacity-75 text-primary">
{{ binaryName }}
</span>
</div>
<div class="space-x-2">
<span>Active Connections:</span>
<span class="text-opacity-75 text-primary">{{stats?.countAliveConnections || 0}}</span>
</div>
<div class="space-x-2">
<span>Network History:</span>
<ng-container *ngIf="historyAvailableSince">
<span class="text-opacity-75 text-primary">As of {{ historyAvailableSince | date }}</span>
<span class="-mt-3 underline cursor-pointer text-primary hover:text-secondary text-xxs"
(click)="cleanProfileHistory()">Remove all {{ connectionsInHistory }} Connections</span>
</ng-container>
<ng-container *ngIf="!historyAvailableSince">
<span class="text-opacity-75 text-primary"
sfng-tooltip="Network History feature is available in Portmaster Plus">None</span>
</ng-container>
</div>
</div>
<!-- Quick Settings -->
<div class="flex flex-row flex-wrap gap-2 items-stretch whitespace-nowrap text-xxs" *ngIf="!collapseHeader" [@fadeIn]
[@fadeOut]>
<app-qs-internet [settings]="profileSettings" (save)="saveSetting($event)">
</app-qs-internet>
<app-qs-history [canUse]="canUseHistory" [settings]="profileSettings" (save)="saveSetting($event)">
</app-qs-history>
<app-qs-use-spn [canUse]="canUseSPN" [settings]="profileSettings" (save)="saveSetting($event)">
</app-qs-use-spn>
<app-qs-select-exit [canUse]="canUseSPN" [settings]="profileSettings" (save)="saveSetting($event)">
</app-qs-select-exit>
<button class="flex flex-row gap-2 items-center px-4 bg-gray-300 btn" cdkOverlayOrigin #overlayOrigin="cdkOverlayOrigin" (click)="profileMenu.dropdown.toggle(overlayOrigin)">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
More
</button>
<app-menu #profileMenu>
<app-menu-item (click)="editProfile()">Edit App Profile</app-menu-item>
<app-menu-item (click)="exportProfile()">Export App Profile</app-menu-item>
<app-menu-item (click)="deleteProfile()">Delete App Profile</app-menu-item>
</app-menu>
<sfng-tipup key="appSettings-QuickSettings"></sfng-tipup>
</div>
</div>
<!-- Statistics -->
<div class="flex flex-row flex-wrap flex-grow gap-4 justify-end items-center pr-8"
*ngIf="!!stats && stats.size > 0">
<div [ngClass]="{
'h-20 sfng-lg:w-32 sfng-lg:h-24': !collapseHeader
}"
class="flex flex-col justify-center items-center px-4 py-1 w-24 bg-gray-300 bg-opacity-75 rounded border border-gray-300 shadow transition-all duration-200">
<h2 class="p-0 m-0 text-lg sfng-lg:text-xl text-primary">{{ stats!.size | prettyCount }}</h2>
<span class="text-secondary">Connections</span>
</div>
<div [ngClass]="{
'h-20 sfng-lg:w-32 sfng-lg:h-24': !collapseHeader
}"
class="flex flex-col justify-center items-center px-4 py-1 w-24 bg-gray-300 bg-opacity-75 rounded border border-gray-300 shadow transition-all duration-200">
<h2 class="p-0 m-0 text-lg sfng-lg:text-xl text-primary">{{ (100 / stats!.size) * (stats!.size
- stats!.countAllowed) | number:'1.0-1' }}%</h2>
<span class="text-secondary">Blocked</span>
</div>
<div [ngClass]="{
'h-20 sfng-lg:w-32 sfng-lg:h-24': !collapseHeader
}"
class="flex flex-col justify-center items-center px-4 py-1 w-24 bg-gray-300 bg-opacity-75 rounded border border-gray-300 shadow transition-all duration-200">
<h2 *ngIf="canViewBW; else: cannotViewBW"
class="p-0 m-0 text-lg whitespace-nowrap sfng-lg:text-xl text-primary">
{{ stats.bytes_received | bytes }}
</h2>
<ng-template #cannotViewBW>
<span routerLink="/dashboard"
class="p-0 pb-2.5 m-0 text-opacity-50 whitespace-nowrap text-xxs sfng-lg:text-xs text-tertiary hover:underline">
Available in Plus
</span>
</ng-template>
<span class="text-secondary">Received</span>
</div>
<div [ngClass]="{
'h-20 sfng-lg:w-32 sfng-lg:h-24': !collapseHeader
}"
class="flex flex-col justify-center items-center px-4 py-1 w-24 bg-gray-300 bg-opacity-75 rounded border border-gray-300 shadow transition-all duration-200">
<h2 *ngIf="canViewBW; else: cannotViewBW"
class="p-0 m-0 text-lg whitespace-nowrap sfng-lg:text-xl text-primary">
{{ stats.bytes_sent | bytes }}
</h2>
<span class="text-secondary">Sent</span>
</div>
</div>
</div>
<div class="absolute bottom-0 right-10 z-10 cursor-pointer hover:text-primary text-secondary"
(click)="collapseHeader = !collapseHeader">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6 transition-all duration-200" [class.rotate-180]="collapseHeader">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
</div>
</div>
<sfng-tab-group class="flex overflow-hidden flex-col flex-grow p-4 px-12 w-full">
<!-- Connections -->
<sfng-tab id="connections" title="Connections">
<div *sfngTabContent>
<sfng-netquery-viewer [filters]="['allowed', 'as_owner', 'country', 'domain']"
[mergeFilter]="{profile: appProfile.Source + '/' + appProfile.ID}">
</sfng-netquery-viewer>
</div>
</sfng-tab>
<!-- App Settings -->
<sfng-tab id="settings" title="Settings">
<div *sfngTabContent class="overflow-auto py-4" cdkScrollable>
<div class="flex flex-row items-center pr-2 mb-4 space-x-4">
<input type="text" [(ngModel)]="searchTerm" placeholder="Search Settings">
<a href="https://docs.safing.io/portmaster/settings?source=Portmaster"
class="flex flex-row gap-1 justify-center items-center self-stretch px-2 whitespace-nowrap bg-gray-300 rounded hover:bg-gray-200 text-blue">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
Get Help
</a>
<div sfngTipUpAnchor="left" class="flex space-x-2 flex-rows">
<sfng-tipup key="appSettings-Filter"></sfng-tipup>
<sfng-select [ngModel]="viewSetting" (ngModelChange)="viewSettingChange.next($event)"
sfngTipUpTrigger="appSettings-Filter" sfngTipUpAnchor="left" sfngTipUpPassive>
<sfng-select-item *sfngSelectValue="'all'">
View All
</sfng-select-item>
<sfng-select-item *sfngSelectValue="'active'">
View Active
</sfng-select-item>
</sfng-select>
</div>
</div>
<div class="flex items-center text-tertiary">
<div class="inline-flex items-center" sfngTipUpAnchor=>
<span class="mr-3 text-xxs">App Specific Settings</span>
<sfng-tipup key="appSettings"></sfng-tipup>
</div>
</div>
<ng-container *ngIf="settings.length > 0; else: noSettingsTemplate">
<app-settings-view [searchTerm]="searchTerm" [availableSettings]="settings" compactView="true"
[highlightKey]="highlightSettingKey" userSettingsMarker="true" (save)="saveSetting($event)"
resetLabelText="Use global setting" lockDefaults="true" displayStackable="true" [scope]="appProfile.Source + '/' + appProfile.ID">
</app-settings-view>
</ng-container>
<ng-template #noSettingsTemplate>
<div class="flex flex-col items-center mt-32">
<svg xmlns="http://www.w3.org/2000/svg" class="w-32 h-32 text-opacity-50 text-tertiary" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
clip-rule="evenodd" />
</svg>
<p class="text-sm">
<span class="text-primary">
{{ appProfile!.Name }}
</span>
is fully using the global settings.
</p>
<p class="mb-4 text-sm">
Start creating exceptions for it now.
</p>
<button (click)="viewSettingChange.next('all')">Edit Settings</button>
</div>
</ng-template>
</div>
</sfng-tab>
<!-- Details -->
<sfng-tab id="details" title="Details" [warning]="displayWarning">
<div *sfngTabContent class="overflow-auto py-4 space-y-8" cdkScrollable>
<div class="grid grid-cols-2 gap-4 text-primary text-xxs">
<div class="flex flex-col justify-center p-4 bg-gray-200 rounded">
<p class="space-x-2">
<label class="text-secondary">Name:</label>
<span>{{appProfile!.Name}}</span>
</p>
<p class="space-x-2">
<label class="text-secondary">Path:</label>
<span>{{appProfile!.PresentationPath}}</span>
</p>
</div>
<div class="flex flex-col justify-center p-4 bg-gray-200 rounded">
<p class="space-x-2">
<label class="text-secondary">Created:</label>
<span>{{appProfile!.Created * 1000 | date:'medium'}}</span>
</p>
<p class="space-x-2">
<label class="text-secondary">Last Edited:</label>
<span *ngIf="!!appProfile.LastEdited">{{appProfile!.LastEdited * 1000 | date:'medium'}}</span>
<span *ngIf="!appProfile.LastEdited">N/A</span>
</p>
</div>
<ng-container *appExpertiseLevel="'developer'">
<div class="flex flex-col justify-center p-4 bg-gray-200 rounded">
<p class="space-x-2">
<label class="text-secondary">Internal:</label>
<span>{{!!appProfile!.Internal ? 'yes' : 'no'}}</span>
</p>
<p class="space-x-2">
<label class="text-secondary">Source:</label>
<span>{{appProfile!.Source}}</span>
</p>
<p class="space-x-2">
<label class="text-secondary">ID:</label>
<span>{{appProfile!.ID}}</span>
</p>
</div>
<div class="flex flex-col justify-center p-4 bg-gray-200 rounded">
<p class="space-x-2">
<label class="text-secondary">Revision:</label>
<span>{{layeredProfile?.RevisionCounter}}</span>
</p>
<p class="space-x-2">
<label class="text-secondary">Layers:</label>
<span>
<ol class="inline-block">
<li *ngFor="let layer of layeredProfile?.LayerIDs"
[routerLink]="['/', 'app'].concat(layer.split('/'))">
{{layer}}
</li>
</ol>
</span>
</p>
</div>
</ng-container>
</div>
<!-- Description Section -->
<div class="flex flex-col space-y-4" *ngIf="!!appProfile?.Description">
<h2 class="flex flex-row items-center p-0 m-0 mr-2 mb-4 text-opacity-75 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 w-5 h-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="mr-2 text-xxs">Description</span>
<span class="inline-block flex-grow border-b border-gray-400"></span>
</h2>
<markdown emoji [data]="appProfile.Description"
class="block self-stretch p-4 -mb-4 ml-2 w-auto h-auto text-secondary">
</markdown>
</div>
<!-- Warning Section -->
<div class="flex flex-col space-y-4" *ngIf="displayWarning">
<h2 class="flex flex-row items-center p-0 m-0 mr-2 mb-4 text-opacity-75 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 w-5 h-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span class="mr-2 text-xxs">Warning</span>
<span class="inline-block flex-grow border-b border-gray-400"></span>
</h2>
<markdown emoji [data]="appProfile.Warning"
class="block self-stretch p-4 ml-2 w-auto h-auto border-l text-secondary border-yellow">
</markdown>
<span class="text-tertiary text-xxs" *ngIf="appProfile?.WarningLastUpdated">updated
{{ appProfile.WarningLastUpdated | timeAgo }}</span>
</div>
<!-- Fingerprints -->
<div class="space-y-4 text-xxs">
<h2 class="flex flex-row items-center p-0 m-0 mr-2 mb-4 text-opacity-75 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="mr-1 w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33" />
</svg>
<span class="mr-2 text-xxs">Fingerprints</span>
<span class="inline-block flex-grow border-b border-gray-400"></span>
</h2>
<span class="text-xs text-secondary">This profile will be applied to processes that match one of the following
fingerprints:</span>
<div
class="flex relative flex-row gap-2 items-center p-2 mx-3 bg-gray-200 border-r border-l border-gray-500 w-fit"
*ngFor="let fp of appProfile.Fingerprints">
<span class="block absolute top-0 left-0 w-2 border-b border-gray-500"></span>
<span class="block absolute bottom-0 left-0 w-2 border-b border-gray-500"></span>
<span class="block absolute top-0 right-0 w-2 border-b border-gray-500"></span>
<span class="block absolute right-0 bottom-0 w-2 border-b border-gray-500"></span>
<span class="inline-block px-2 py-1 bg-gray-400 rounded">{{ fp.Type }}</span>
<ng-container *ngIf="!!fp.Key">
<span class="text-secondary">where</span>
<span
class="inline-block px-2 py-1 bg-gray-400 rounded">{{ fp.Type === 'tag' ? (tagNames[fp.Key] || fp.Key) : fp.Key }}</span>
</ng-container>
<span class="inline-block px-2 py-1 bg-gray-400 rounded">{{ fp.Operation }}</span>
<span class="inline-block px-2 py-1 bg-gray-400 rounded">{{ fp.Value }}</span>
</div>
</div>
<!-- Delete Profile Section -->
<div class="space-y-4">
<h2 class="flex flex-row items-center p-0 m-0 mr-2 mb-4 text-opacity-75 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 w-5 h-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span class="mr-2 text-xxs">Delete Profile</span>
<span class="inline-block flex-grow border-b border-gray-400"></span>
</h2>
<span class="text-secondary">You can completely delete this profile to get rid of any settings. The profile
will
be automatically re-created with default settings as soon as the application starts to use the
network.</span>
<button class="block mt-2" (click)="deleteProfile()">Delete Profile</button>
</div>
<!-- Debug Section -->
<div class="space-y-4">
<h2 class="flex flex-row items-center p-0 m-0 mr-2 mb-4 text-opacity-75 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="mr-1 w-5 h-5">
<g fill="none">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="m18 7-1 2-1.333.917M5 12h3.11M15.89 12H19M6 3H5c-1.105 0-2 .895-2 2v1M18 21h1c1.105 0 2-.895 2-2v-1M3 18v1c0 1.105.895 2 2 2h1M21 6V5c0-1.105-.895-2-2-2h-1M6 7l1 2 1.333.917M12.444 17h-.889c-1.657 0-3-1.343-3-3v-3c0-1.105.895-2 2-2h2.889c1.105 0 2 .895 2 2v3c0 1.657-1.343 3-3 3ZM6 17l1-2 1.333-.917M18 17l-1-2-1.333-.917M14 9h-4V7c0-.552.448-1 1-1h2c.552 0 1 .448 1 1v2Z" />
</g>
</svg>
<span class="mr-2 text-xxs">Debugging</span>
<span class="inline-block flex-grow border-b border-gray-400"></span>
</h2>
<span class="text-secondary">When reporting issues with this app please make sure to include the
following
debug information:</span>
<button class="block mt-2" (click)="copyDebugInfo()">Copy Debug Information</button>
</div>
</div>
</sfng-tab>
<sfng-tab id="insights" title="Insights">
<div *sfngTabContent class="py-4 space-y-8 overflow-auto" cdkScrollable>
<app-app-insights [profile]="appProfile"></app-app-insights>
</div>
</sfng-tab>
</sfng-tab-group>
</ng-container>
<app-settings-overview *ngIf="showOverview" class="p-4 px-12"></app-settings-overview>

View File

@@ -0,0 +1,3 @@
:host {
@apply flex flex-col h-screen max-h-screen;
}

View File

@@ -0,0 +1,641 @@
import {
ChangeDetectorRef,
Component,
DestroyRef,
OnDestroy,
OnInit,
ViewChild,
inject,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import {
AppProfile,
AppProfileService,
Condition,
ConfigService,
Database,
DebugAPI,
ExpertiseLevel,
FeatureID,
FlatConfigObject,
IProfileStats,
LayeredProfile,
Netquery,
PortapiService,
SPNService,
Setting,
flattenProfileConfig,
setAppSetting
} from '@safing/portmaster-api';
import { SfngDialogService } from '@safing/ui';
import {
BehaviorSubject,
Observable,
Subscription,
combineLatest,
interval,
of,
throwError,
} from 'rxjs';
import {
catchError,
distinctUntilChanged,
map,
mergeMap,
startWith,
switchMap,
} from 'rxjs/operators';
import { INTEGRATION_SERVICE } from 'src/app/integration';
import { SessionDataService } from 'src/app/services';
import { ActionIndicatorService } from 'src/app/shared/action-indicator';
import { fadeInAnimation, fadeOutAnimation } from 'src/app/shared/animations';
import {
ExportConfig,
ExportDialogComponent,
} from 'src/app/shared/config/export-dialog/export-dialog.component';
import { SaveSettingEvent } from 'src/app/shared/config/generic-setting/generic-setting';
import { ExpertiseService } from 'src/app/shared/expertise';
import { SfngNetqueryViewer } from 'src/app/shared/netquery';
import { EditProfileDialog } from './../../shared/edit-profile-dialog/edit-profile-dialog';
@Component({
templateUrl: './app-view.html',
styleUrls: ['../page.scss', './app-view.scss'],
animations: [fadeOutAnimation, fadeInAnimation],
})
export class AppViewComponent implements OnInit, OnDestroy {
private readonly integration = inject(INTEGRATION_SERVICE);
@ViewChild(SfngNetqueryViewer)
netqueryViewer?: SfngNetqueryViewer;
destroyRef = inject(DestroyRef);
spn = inject(SPNService);
canUseHistory = false;
canViewBW = false;
canUseSPN = false;
/** subscription to our update-process observable */
private subscription = Subscription.EMPTY;
/**
* @private
* historyAvailableSince holds the date of the oldes connection
* in the history database for this app.
*/
historyAvailableSince: Date | null = null;
/**
* @private
* connectionsInHistory holds the total amount of connections
* in the history database for this app
*/
connectionsInHistory = 0;
/**
* @private
* The current AppProfile we are showing.
*/
appProfile: AppProfile | null = null;
/**
* @private
* Whether or not the overview componet should be rendered.
*/
get showOverview() {
return this.appProfile == null && !this._loading;
}
/**
* @private
* The currently displayed list of settings
*/
settings: Setting[] = [];
profileSettings: Setting[] = [];
/**
* @private
* All available settings.
*/
allSettings: Setting[] = [];
/**
* @private
* The current search term displayed in the search-input.
*/
searchTerm = '';
/**
* @private
* The key of the setting to highligh, if any ...
*/
highlightSettingKey: string | null = null;
/**
* @private
* Emits whenever the currently used settings "view" changes.
*/
viewSettingChange = new BehaviorSubject<'all' | 'active'>('all');
/**
* @private
* The path of the application binary
*/
applicationDirectory = '';
/**
* @private
* The name of the binary
*/
binaryName = '';
/**
* @private
* Whether or not the profile warning message should be displayed
*/
displayWarning = false;
/**
* @private
* The current profile statistics
*/
stats: IProfileStats | null = null;
/**
* @private
* The internal, layered profile if the app is active
*/
layeredProfile: LayeredProfile | null = null;
/** Used to track whether we are already initialized */
private _loading = true;
/**
* @private
*
* Defines what "view" we are currently in
*/
get viewSetting(): 'all' | 'active' {
return this.viewSettingChange.getValue();
}
/** A lookup map from tag ID to tag Name */
tagNames: {
[tagID: string]: string;
} = {};
collapseHeader = false;
constructor(
public sessionDataService: SessionDataService,
private profileService: AppProfileService,
private route: ActivatedRoute,
private netquery: Netquery,
private cdr: ChangeDetectorRef,
private configService: ConfigService,
private router: Router,
private actionIndicator: ActionIndicatorService,
private dialog: SfngDialogService,
private debugAPI: DebugAPI,
private expertiseService: ExpertiseService,
private portapi: PortapiService
) { }
/**
* @private
* Used to save a change in the app settings. Emitted by the config-view
* component
*
* @param event The emitted save-settings-event.
*/
saveSetting(event: SaveSettingEvent) {
// Guard against invalid usage and abort if there's not appProfile
// to save.
if (!this.appProfile) {
return;
}
if (!this.appProfile!.Config) {
this.appProfile.Config = {}
}
// If the value has been "reset to global value" we need to
// set the value to "undefined".
if (event.isDefault) {
setAppSetting(this.appProfile!.Config, event.key, undefined);
} else {
setAppSetting(this.appProfile!.Config, event.key, event.value);
}
// Actually safe the profile
this.profileService.saveProfile(this.appProfile!).subscribe({
next: () => {
if (!!event.accepted) {
event.accepted();
}
},
error: (err) => {
// if there's a callback function for errors call it.
if (!!event.rejected) {
event.rejected(err);
}
console.error(err);
this.actionIndicator.error('Failed to save setting', err);
},
});
}
exportProfile() {
if (!this.appProfile) {
return;
}
this.portapi
.exportProfile(`${this.appProfile.Source}/${this.appProfile.ID}`)
.subscribe((exportBlob) => {
const exportConfig: ExportConfig = {
type: 'profile',
content: exportBlob,
};
this.dialog.create(ExportDialogComponent, {
data: exportConfig,
autoclose: false,
backdrop: true,
});
});
}
editProfile() {
if (!this.appProfile) {
return;
}
this.dialog
.create(EditProfileDialog, {
backdrop: true,
autoclose: false,
data: `${this.appProfile.Source}/${this.appProfile.ID}`,
})
.onAction('deleted', () => {
// navigate to the app overview if it has been deleted.
this.router.navigate(['/app/']);
});
}
cleanProfileHistory() {
if (!this.appProfile) {
return;
}
const observer = this.actionIndicator.httpObserver(
'History successfully removed',
'Failed to remove history'
);
this.netquery
.cleanProfileHistory(this.appProfile.Source + '/' + this.appProfile.ID)
.subscribe({
next: (res) => {
observer.next!(res);
this.historyAvailableSince = null;
this.connectionsInHistory = 0;
this.cdr.markForCheck();
},
error: (err) => {
observer.error!(err);
},
});
}
ngOnInit() {
this.profileService.tagDescriptions().subscribe((tags) => {
tags.forEach((t) => {
this.tagNames[t.ID] = t.Name;
this.cdr.markForCheck();
});
});
// watch the route parameters and start watching the referenced
// application profile, it's layer profile and polling the stats.
const profileStream: Observable<
[AppProfile, LayeredProfile | null, IProfileStats | null] | null
> = this.route.paramMap.pipe(
switchMap((params) => {
// Get the profile source and id. If one is unset (null)
// than return a"null" emit-once stream.
const source = params.get('source');
const id = params.get('id');
if (source === null || id === null) {
this._loading = false;
return of(null);
}
this._loading = true;
this.historyAvailableSince = null;
this.connectionsInHistory = 0;
this.appProfile = null;
this.stats = null;
// Start watching the application profile.
// switchMap will unsubscribe automatically if
// we start watching a different profile.
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)
),
]);
})
);
})
);
// used to track changes to the object identity of the global configuration
let prevousGlobal: FlatConfigObject = {};
this.subscription = combineLatest([
profileStream, // emits the current app profile everytime it changes
this.route.queryParamMap, // for changes to the settings= query parameter
this.profileService.globalConfig(), // for changes to ghe global profile
this.configService.query(''), // get ALL settings (once, only the defintion is of intereset)
this.viewSettingChange.pipe(
// watch the current "settings-view" setting, but only if it changes
distinctUntilChanged()
),
]).subscribe(
async ([profile, queryMap, global, allSettings, viewSetting]) => {
const previousProfile = this.appProfile;
if (!!profile) {
const key = profile![0].Source + '/' + profile![0].ID;
const query: Condition = {
profile: key,
};
// ignore internal connections if the user is not in developer mode.
if (this.expertiseService.currentLevel !== ExpertiseLevel.Developer) {
query.internal = {
$eq: false,
};
}
this.netquery
.query(
{
select: [
{
$min: {
field: 'started',
as: 'first_connection',
},
},
{
$count: {
field: '*',
as: 'totalCount',
},
},
],
groupBy: ['profile'],
query: {
profile: `${profile[0].Source}/${profile[0].ID}`,
},
databases: [Database.History],
},
'app-view-get-first-connection'
)
.subscribe((result) => {
if (result.length > 0) {
this.historyAvailableSince = new Date(
result[0].first_connection!
);
this.connectionsInHistory = result[0].totalCount;
} else {
this.historyAvailableSince = null;
this.connectionsInHistory = 0;
}
this.cdr.markForCheck();
});
this.appProfile = profile[0] || null;
this.layeredProfile = profile[1] || null;
this.stats = profile[2] || null;
} else {
this.appProfile = null;
this.layeredProfile = null;
this.stats = null;
}
this.displayWarning = false;
if (this.appProfile?.WarningLastUpdated) {
const now = new Date().getTime();
const diff =
now - new Date(this.appProfile.WarningLastUpdated).getTime();
this.displayWarning = diff < 1000 * 60 * 60 * 24 * 7;
}
if (!!this.netqueryViewer && this._loading) {
this.netqueryViewer.performSearch();
}
this._loading = false;
if (!!this.appProfile?.PresentationPath) {
let parts: string[] = [];
let sep = '/';
if (this.appProfile.PresentationPath[0] === '/') {
// linux, darwin, bsd ...
sep = '/';
} else {
// windows ...
sep = '\\';
}
parts = this.appProfile.PresentationPath.split(sep);
this.binaryName = parts.pop()!;
this.applicationDirectory = parts.join(sep);
} else {
this.applicationDirectory = '';
this.binaryName = '';
}
this.highlightSettingKey = queryMap.get('setting');
let profileConfig: FlatConfigObject = {};
// if we have a profile flatten it's configuration map to something
// more useful.
if (!!this.appProfile) {
profileConfig = flattenProfileConfig(this.appProfile.Config);
}
// if we should highlight a setting make sure to switch the
// viewSetting to all if it's the "global" default (that is, no
// value is set). Otherwise the setting won't render and we cannot
// highlight it.
// We need to keep this even though we default to "all" now since
// the following might happen:
// - user already navigated to an app-page and selected "View Active".
// - a notification comes in that has a "show setting" action
// - the user clicks the action button and the setting should be displayed
// - since the requested setting has not been changed it is not available
// in "View Active" so we need to switch back to "View All". Otherwise
// the action button would fail and the user would not notice something
// changing.
//
if (!!this.highlightSettingKey) {
if (profileConfig[this.highlightSettingKey] === undefined) {
this.viewSettingChange.next('all');
}
}
// check if we got new values for the profile or the settings. In both cases, we need to update the
// profile settings displayed as there might be new values to show.
const profileChanged = previousProfile !== this.appProfile;
const settingsChanged = allSettings !== this.allSettings;
const globalChanged = global !== prevousGlobal;
const settingsNeedUpdate =
profileChanged || settingsChanged || globalChanged;
// save the current global config object so we can compare for identity changes
// the next time we're executed
prevousGlobal = global;
if (!!this.appProfile && settingsNeedUpdate) {
// filter the settings and remove all settings that are not
// profile specific (i.e. not part of the global config). Also
// update the current settings value (from the app profile) and
// the default value (from the global profile).
this.profileSettings = allSettings.map((setting) => {
setting.Value = profileConfig[setting.Key];
setting.GlobalDefault = global[setting.Key];
return setting;
});
this.settings = this.profileSettings.filter((setting) => {
if (!(setting.Key in global)) {
return false;
}
const isModified = setting.Value !== undefined;
if (this.viewSetting === 'all') {
return true;
}
return isModified;
});
this.allSettings = allSettings;
}
this.cdr.markForCheck();
}
);
this.spn.profile$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (profile) => {
this.canUseHistory =
profile?.current_plan?.feature_ids?.includes(FeatureID.History) ||
false;
this.canViewBW =
profile?.current_plan?.feature_ids?.includes(FeatureID.Bandwidth) ||
false;
this.canUseSPN =
profile?.current_plan?.feature_ids?.includes(FeatureID.SPN) || false;
},
});
}
/**
* @private
* Retrieves debug information from the current
* profile and copies it to the clipboard
*/
copyDebugInfo() {
if (!this.appProfile) {
return;
}
this.debugAPI
.getProfileDebugInfo(this.appProfile.Source, this.appProfile.ID)
.subscribe(async (data) => {
console.log(data);
// Copy to clip-board if supported
await this.integration.writeToClipboard(data);
this.actionIndicator.success('Copied to Clipboard');
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
/**
* @private
* Delete the current profile. Requires a two-step confirmation.
*/
deleteProfile() {
if (!this.appProfile) {
return;
}
this.dialog
.confirm({
canCancel: true,
caption: 'Caution',
header: 'Deleting Profile ' + this.appProfile.Name,
message:
'Do you really want to delete this profile? All settings will be lost.',
buttons: [
{ id: '', text: 'Cancel', class: 'outline' },
{ id: 'delete', class: 'danger', text: 'Yes, delete it' },
],
})
.onAction('delete', () => {
this.profileService.deleteProfile(this.appProfile!).subscribe(() => {
this.router.navigate(['/app/overview']);
this.actionIndicator.success(
'Profile Deleted',
'Successfully deleted profile ' + this.appProfile?.Name
);
});
});
}
}

View File

@@ -0,0 +1,3 @@
export { AppViewComponent } from './app-view';
export { AppOverviewComponent } from './overview';
export { QuickSettingInternetButtonComponent } from './qs-internet';

View File

@@ -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()">Cancel</button>
<button class="bg-blue text-white" (click)="mergeProfiles()" [disabled]="!primary || !newName">Merge</button>
</div>

View File

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

View File

@@ -0,0 +1,193 @@
<div class="flex flex-row justify-between items-center mb-4">
<input
type="text"
placeholder="Search"
[ngModel]="searchTerm"
(ngModelChange)="searchApps($event)"
[autoFocus]="true"
/>
<app-expertise></app-expertise>
</div>
<div class="header-title">
<h1>
All Apps
<sfng-tipup key="appsTitle"></sfng-tipup>
</h1>
<div class="flex-grow"></div>
<app-menu #profileMenu>
<app-menu-item (click)="createProfile()">Create profile</app-menu-item>
<app-menu-item (click)="importProfile()">Import 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">Cancel</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">
<ng-container *ngIf="runningProfiles.length > 0">
<div class="scrollable-header">
<h4>Active</h4>
</div>
<ng-container
*ngTemplateOutlet="profileList; context: {$implicit: runningProfiles}"
></ng-container>
</ng-container>
<ng-container *ngIf="recentlyEdited.length > 0">
<div class="scrollable-header">
<h4>Recently Edited</h4>
</div>
<ng-container
*ngTemplateOutlet="profileList; context: {$implicit: recentlyEdited}"
></ng-container>
</ng-container>
<ng-container *ngIf="profiles.length > 0">
<div class="scrollable-header">
<h4>All</h4>
</div>
<ng-container
*ngTemplateOutlet="profileList; context: {$implicit: profiles}"
></ng-container>
</ng-container>
<ng-template #profileList let-list>
<ng-container *ngFor="let profile of list; trackBy: trackProfile">
<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 top-1 right-1 w-2 h-2 rounded-full bg-blue"
></span>
</div>
</ng-container>
<ng-container *ngIf="loading">
<div class="card-header">
<fa-icon class="card-icon loading" icon="square"></fa-icon>
<app-text-placeholder
mode="input"
width="small"
class="card-title"
></app-text-placeholder>
</div>
<div class="card-header">
<fa-icon class="card-icon loading" icon="square"></fa-icon>
<app-text-placeholder
mode="input"
width="small"
class="card-title"
></app-text-placeholder>
</div>
<div class="card-header">
<fa-icon class="card-icon loading" icon="square"></fa-icon>
<app-text-placeholder
mode="input"
width="7rem"
class="card-title"
></app-text-placeholder>
</div>
<div class="card-header">
<fa-icon class="card-icon loading" icon="square"></fa-icon>
<app-text-placeholder
mode="input"
width="3rem"
class="card-title"
></app-text-placeholder>
</div>
</ng-container>
</ng-template>
</div>
<div
*ngIf="total === 0 && searchTerm !== ''"
class="flex justify-center items-center p-2 bg-gray-200 text-secondary"
>
No applications match your search term.
</div>

View File

@@ -0,0 +1,54 @@
:host {
justify-content: flex-start;
}
.header-title {
display: flex;
width: 100%;
margin-bottom: 0.5rem;
align-items: center;
height: 3rem;
flex-shrink: 0;
h1 {
flex-grow: unset;
}
fa-icon[icon*="question-circle"] {
margin-left: 0.35rem;
}
}
.scrollable {
width: auto;
flex-grow: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.scrollable-header {
@apply bg-background;
@apply pt-4;
@apply pb-1;
width: 100%;
position: sticky;
top: 0px;
display: flex;
grid-column: 1 / -1;
fa-icon[icon*="question-circle"] {
margin-left: 0.35rem;
}
}
.card-header {
// Card headers have top-margin by default.
// Since we're using a grid-gap anyway we need
// to clear the margin.
@apply mt-0;
}

View File

@@ -0,0 +1,305 @@
import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
TrackByFunction,
} from '@angular/core';
import {
AppProfile,
AppProfileService,
Netquery,
trackById,
} from '@safing/portmaster-api';
import { SfngDialogService } from '@safing/ui';
import { BehaviorSubject, Subscription, combineLatest, forkJoin } from 'rxjs';
import { debounceTime, filter, startWith } from 'rxjs/operators';
import {
fadeInAnimation,
fadeInListAnimation,
moveInOutListAnimation,
} from 'src/app/shared/animations';
import { FuzzySearchService } from 'src/app/shared/fuzzySearch';
import { EditProfileDialog } from './../../shared/edit-profile-dialog/edit-profile-dialog';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { MergeProfileDialogComponent } from './merge-profile-dialog/merge-profile-dialog.component';
import { ActionIndicatorService } from 'src/app/shared/action-indicator';
import { Router } from '@angular/router';
import {
ImportConfig,
ImportDialogComponent,
} from 'src/app/shared/config/import-dialog/import-dialog.component';
interface LocalAppProfile extends AppProfile {
hasConfigChanges: boolean;
selected: boolean;
}
@Component({
selector: 'app-settings-overview',
templateUrl: './overview.html',
styleUrls: ['../page.scss', './overview.scss'],
animations: [fadeInAnimation, fadeInListAnimation, moveInOutListAnimation],
})
export class AppOverviewComponent implements OnInit, OnDestroy {
private subscription = Subscription.EMPTY;
/** Whether or not we are currently loading */
loading = true;
/** All application profiles that are actually running */
runningProfiles: LocalAppProfile[] = [];
/** All application profiles that have been edited recently */
recentlyEdited: LocalAppProfile[] = [];
/** All application profiles */
profiles: LocalAppProfile[] = [];
/** The current search term */
searchTerm: string = '';
/** total number of profiles */
total: number = 0;
/** Whether or not we are in profile-selection mode */
set selectMode(v: any) {
this._selectMode = coerceBooleanProperty(v);
// reset all previous profile selections
if (!this._selectMode) {
this.profiles.forEach((profile) => (profile.selected = false));
}
}
get selectMode() {
return this._selectMode;
}
private _selectMode = false;
get selectedProfileCount() {
return this.profiles.reduce(
(sum, profile) => (profile.selected ? sum + 1 : sum),
0
);
}
/** Observable emitting the search term */
private onSearch = new BehaviorSubject('');
/** TrackBy function for the profiles. */
trackProfile: TrackByFunction<LocalAppProfile> = trackById;
constructor(
private profileService: AppProfileService,
private changeDetector: ChangeDetectorRef,
private searchService: FuzzySearchService,
private netquery: Netquery,
private dialog: SfngDialogService,
private actionIndicator: ActionIndicatorService,
private router: Router
) { }
handleProfileClick(profile: LocalAppProfile, event: MouseEvent) {
if (event.shiftKey) {
// stay on the same page as clicking the app actually triggers
// a navigation before this handler is executed.
this.router.navigate(['/app/overview']);
this.selectMode = true;
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
}
if (this.selectMode) {
profile.selected = !profile.selected;
}
if (event.shiftKey && this.selectedProfileCount === 0) {
this.selectMode = false;
}
}
importProfile() {
const importConfig: ImportConfig = {
type: 'profile',
key: '',
};
this.dialog.create(ImportDialogComponent, {
data: importConfig,
autoclose: false,
backdrop: 'light',
});
}
openMergeDialog() {
this.dialog.create(MergeProfileDialogComponent, {
autoclose: true,
backdrop: 'light',
data: this.profiles.filter((p) => p.selected),
});
this.selectMode = false;
}
deleteSelectedProfiles() {
this.dialog
.confirm({
header: 'Confirm Profile Deletion',
message: `Are you sure you want to delete all ${this.selectedProfileCount} selected profiles?`,
caption: 'Attention',
buttons: [
{
id: 'no',
text: 'Cancel',
class: 'outline',
},
{
id: 'yes',
text: 'Delete',
class: 'danger',
},
],
})
.onAction('yes', () => {
forkJoin(
this.profiles
.filter((profile) => profile.selected)
.map((p) => this.profileService.deleteProfile(p))
).subscribe({
next: () => {
this.actionIndicator.success(
'Selected Profiles Delete',
'All selected profiles have been deleted'
);
},
error: (err) => {
this.actionIndicator.error(
'Failed To Delete Profiles',
`An error occured while deleting some profiles: ${this.actionIndicator.getErrorMessgae(
err
)}`
);
},
});
})
.onClose.subscribe(() => (this.selectMode = false));
}
ngOnInit() {
// watch all profiles and re-emit (debounced) when the user
// enters or chanages the search-text.
this.subscription = combineLatest([
this.profileService.watchProfiles(),
this.onSearch.pipe(debounceTime(100), startWith('')),
this.netquery.getActiveProfileIDs().pipe(startWith([] as string[])),
]).subscribe(([profiles, searchTerm, activeProfiles]) => {
this.loading = false;
// find all profiles that match the search term. For searchTerm="" thsi
// will return all profiles.
const filtered = this.searchService.searchList(profiles, searchTerm, {
ignoreLocation: true,
ignoreFieldNorm: true,
threshold: 0.1,
minMatchCharLength: 3,
keys: ['Name', 'PresentationPath'],
});
// create a lookup map of all profiles we already loaded so we don't loose
// selection state when a profile has been updated.
const oldProfiles = new Map<string, LocalAppProfile>(
this.profiles.map((profile) => [
`${profile.Source}/${profile.ID}`,
profile,
])
);
// Prepare new, empty lists for our groups
this.profiles = [];
this.runningProfiles = [];
this.recentlyEdited = [];
// calcualte the threshold for "recently-used" (1 week).
const recentlyUsedThreshold =
new Date().valueOf() / 1000 - 60 * 60 * 24 * 7;
// flatten the filtered profiles, sort them by name and group them into
// our "app-groups" (active, recentlyUsed, others)
this.total = filtered.length;
filtered
.map((item) => item.item)
.sort((a, b) => {
const aName = a.Name.toLocaleLowerCase();
const bName = b.Name.toLocaleLowerCase();
if (aName > bName) {
return 1;
}
if (aName < bName) {
return -1;
}
return 0;
})
.forEach((profile) => {
const local: LocalAppProfile = {
...profile,
hasConfigChanges:
profile.LastEdited > 0 && Object.keys(profile.Config || {}).length > 0,
selected:
oldProfiles.get(`${profile.Source}/${profile.ID}`)?.selected ||
false,
};
if (activeProfiles.includes(profile.Source + '/' + profile.ID)) {
this.runningProfiles.push(local);
} else if (profile.LastEdited >= recentlyUsedThreshold) {
this.recentlyEdited.push(local);
}
// we always add the profile to "All Apps"
this.profiles.push(local);
});
this.changeDetector.markForCheck();
});
}
/**
* @private
*
* Used as an ngModelChange callback on the search-input.
*
* @param term The search term entered by the user
*/
searchApps(term: string) {
this.searchTerm = term;
this.onSearch.next(term);
}
/**
* @private
*
* Opens the create profile dialog
*/
createProfile() {
const ref = this.dialog.create(EditProfileDialog, {
backdrop: true,
autoclose: false,
});
ref.onClose.pipe(filter((action) => action === 'saved')).subscribe(() => {
// reset the search and reload to make sure the new
// profile shows up
this.searchApps('');
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}

View File

@@ -0,0 +1,12 @@
<div class="relative flex flex-wrap items-center justify-center w-full h-full gap-2 px-3 py-2 bg-gray-300 border border-gray-300 rounded shadow">
<span class="text-primary">
Keep History
</span>
<span *ngIf="!canUse" routerLink="/dashboard" class="cursor-pointer text-tertiary hover:underline">
Get Plus
</span>
<sfng-toggle *ngIf="canUse" [ngModel]="currentValue" (ngModelChange)="updateHistoryEnabled($event)"
[disabled]="(historyFeatureAllowed | async) === false"></sfng-toggle>
</div>

View File

@@ -0,0 +1,67 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
inject,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
BoolSetting,
FeatureID,
SPNService,
Setting,
getActualValue,
} from '@safing/portmaster-api';
import { BehaviorSubject, Observable, map } from 'rxjs';
import { share } from 'rxjs/operators';
import { SaveSettingEvent } from 'src/app/shared/config';
@Component({
selector: 'app-qs-history',
templateUrl: './qs-history.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QsHistoryComponent implements OnChanges {
currentValue = false;
historyFeatureAllowed: Observable<boolean> = inject(SPNService).profile$.pipe(
takeUntilDestroyed(),
map((profile) => {
return (
profile?.current_plan?.feature_ids?.includes(FeatureID.History) || false
);
}),
share({ connector: () => new BehaviorSubject<boolean>(false) })
);
@Input()
canUse: boolean = true;
@Input()
settings: Setting[] = [];
@Output()
save = new EventEmitter<SaveSettingEvent<any>>();
ngOnChanges(changes: SimpleChanges): void {
if ('settings' in changes) {
const historySetting = this.settings.find(
(s) => s.Key === 'history/enable'
) as BoolSetting | undefined;
if (historySetting) {
this.currentValue = getActualValue(historySetting);
}
}
}
updateHistoryEnabled(enabled: boolean) {
this.save.next({
isDefault: false,
key: 'history/enable',
value: enabled,
});
}
}

View File

@@ -0,0 +1 @@
export * from './qs-internet';

View File

@@ -0,0 +1,30 @@
<div
class="relative flex flex-wrap items-center justify-center w-full h-full gap-2 px-3 py-2 bg-gray-300 border border-gray-300 rounded shadow"
snfgTooltipPosition="right" [sfng-tooltip]="interferingSettings.length > 0 ? tooltipTemplate : null">
<span class="text-primary" [class.cursor-pointer]="interferingSettings.length > 0">
Block Connections
</span>
<sfng-toggle *ngIf="currentValue !== 'ask'; else: promptingTemplate" [ngModel]="currentValue === 'block'"
(ngModelChange)="updateUseInternet($event)"></sfng-toggle>
<span class="absolute right-0 block w-2 h-2 bg-yellow-300 border border-gray-100 rounded opacity-75"
style="top: 2px; transform: translateX(-2px)" *ngIf="interferingSettings.length > 0"></span>
<ng-template #promptingTemplate>
<span class="mr-2 outline-none cursor-pointer text-secondary hover:underline" [routerLink]="[]"
[queryParams]="{setting: 'filter/defaultAction', tab: 'settings'}">Prompting</span>
</ng-template>
</div>
<ng-template #tooltipTemplate>
The following enabled settings may interfere:
<ul class="pl-4 list-disc">
<ng-container *ngFor="let setting of interferingSettings">
<li class="cursor-pointer hover:underline" [routerLink]="[]"
[queryParams]="{setting: setting.Key, tab: 'settings'}">
{{ setting.Name }}
</li>
</ng-container>
</ul>
</ng-template>

View File

@@ -0,0 +1,79 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core";
import { Setting, StringSetting, getActualValue } from "@safing/portmaster-api";
import { SaveSettingEvent } from "src/app/shared/config/generic-setting/generic-setting";
const interferingSettings = {
'permit': [
'filter/blockInternet',
'filter/blockLAN',
'filter/blockLocal',
'filter/blockP2P',
'filter/blockInbound',
'filter/endpoints',
],
'block': [
'filter/endpoints',
],
}
@Component({
selector: 'app-qs-internet',
templateUrl: './qs-internet.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QuickSettingInternetButtonComponent implements OnChanges {
@Input()
settings: Setting[] = [];
@Output()
save = new EventEmitter<SaveSettingEvent>();
currentValue = ''
interferingSettings: Setting[] = [];
ngOnChanges(changes: SimpleChanges): void {
if ('settings' in changes) {
this.currentValue = '';
const defaultActionSetting = this.settings.find(s => s.Key == 'filter/defaultAction') as (StringSetting | undefined);
if (!!defaultActionSetting) {
this.currentValue = getActualValue(defaultActionSetting);
this.updateInterfering();
}
}
}
updateUseInternet(blocked: boolean) {
const newValue = blocked ? 'block' : 'permit';
this.save.next({
isDefault: false,
key: 'filter/defaultAction',
value: newValue,
})
}
private updateInterfering() {
this.interferingSettings = [];
if (this.currentValue !== 'permit' && this.currentValue !== 'block') {
return;
}
// create a lookup map for setting key to setting
const lm = new Map<string, Setting>();
this.settings.forEach(s => lm.set(s.Key, s))
this.interferingSettings = interferingSettings[this.currentValue]
.map(key => lm.get(key))
.filter(setting => {
if (!setting) {
return false;
}
const value = getActualValue(setting);
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}) as Setting[];
}
}

View File

@@ -0,0 +1 @@
export * from './qs-select-exit';

View File

@@ -0,0 +1,39 @@
<div class="qs-select-exit relative flex flex-wrap items-center justify-center w-full h-full gap-2 px-3 py-1 bg-gray-300 border border-gray-300 rounded shadow">
<span class="text-primary">
SPN Exit
</span>
<span *ngIf="!canUse" routerLink="/dashboard" class="cursor-pointer text-tertiary hover:underline">
Get Pro
</span>
<sfng-select *ngIf="canUse && spnEnabled === true && exitRuleSetting"
[ngModel]="selectedExitRules" (ngModelChange)="updateExitRules($event)" placeholder="Custom"
class="">
<sfng-select-item *sfngSelectValue="">
Automatic
</sfng-select-item>
<ng-container *ngFor="let option of availableExitRules">
<sfng-select-item *sfngSelectValue="option.Value.join(',')">
<span *ngIf="option.FlagID" [appCountryFlags]="option.FlagID"></span>
<span *ngIf="!option.FlagID" class="text-tertiary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-globe" viewBox="0 0 16 16">
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
</svg>
</span>
{{ option.Name }}
</sfng-select-item>
</ng-container>
</sfng-select>
<ng-template *ngIf="canUse && spnEnabled === true && exitRuleSetting === null">
<fa-icon icon="spinner" [spin]="true"></fa-icon>
</ng-template>
<span *ngIf="canUse && spnEnabled === false"
routerLink="/spn" class="cursor-pointer text-tertiary hover:underline"
sfng-tooltip="Enable SPN to start using.">
Disabled
</span>
</div>

View File

@@ -0,0 +1,128 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
inject,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
BoolSetting,
StringArraySetting,
CountrySelectionQuickSetting,
ConfigService,
Setting,
getActualValue,
} from '@safing/portmaster-api';
import { SaveSettingEvent } from 'src/app/shared/config/generic-setting/generic-setting';
@Component({
selector: 'app-qs-select-exit',
templateUrl: './qs-select-exit.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QuickSettingSelectExitButtonComponent
implements OnInit, OnChanges
{
private destroyRef = inject(DestroyRef);
@Input()
canUse: boolean = true;
@Input()
settings: Setting[] = [];
@Output()
save = new EventEmitter<SaveSettingEvent>();
spnEnabled: boolean | null = null;
exitRuleSetting: StringArraySetting | null = null;
selectedExitRules: string | undefined = undefined;
availableExitRules: CountrySelectionQuickSetting<string[]>[] | null = null;
constructor(
private configService: ConfigService,
private cdr: ChangeDetectorRef
) {}
updateExitRules(newExitRules: string) {
this.selectedExitRules = newExitRules;
let newConfigValue: string[] = [];
if (!!newExitRules) {
newConfigValue = newExitRules.split(',');
}
this.save.next({
isDefault: false,
key: 'spn/exitHubPolicy',
value: newConfigValue,
});
}
ngOnChanges(changes: SimpleChanges): void {
if ('settings' in changes) {
this.exitRuleSetting = null;
this.selectedExitRules = undefined;
const exitRuleSetting = this.settings.find(
(s) => s.Key == 'spn/exitHubPolicy'
) as StringArraySetting | undefined;
if (exitRuleSetting) {
this.exitRuleSetting = exitRuleSetting;
this.updateOptions();
}
}
}
ngOnInit() {
this.configService
.watch<BoolSetting>('spn/enable')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
this.spnEnabled = value;
this.updateOptions();
});
}
private updateOptions() {
if (!this.exitRuleSetting) {
this.selectedExitRules = undefined;
this.availableExitRules = null;
return;
}
if (!!this.exitRuleSetting.Value && this.exitRuleSetting.Value.length > 0) {
this.selectedExitRules = this.exitRuleSetting.Value.join(',');
}
this.availableExitRules = this.getQuickSettings();
this.cdr.markForCheck();
}
private getQuickSettings(): CountrySelectionQuickSetting<string[]>[] {
if (!this.exitRuleSetting) {
return [];
}
let val = this.exitRuleSetting.Annotations[
'safing/portbase:ui:quick-setting'
] as CountrySelectionQuickSetting<string[]>[];
if (val === undefined) {
return [];
}
if (!Array.isArray(val)) {
return [];
}
return val;
}
}

View File

@@ -0,0 +1 @@
export * from './qs-use-spn';

View File

@@ -0,0 +1,42 @@
<div
class="relative flex flex-wrap items-center justify-center w-full h-full gap-2 px-3 py-2 bg-gray-300 border border-gray-300 rounded shadow"
snfgTooltipPosition="right"
[sfng-tooltip]="interferingSettings.length > 0 ? tooltipTemplate : null">
<span class="text-primary" [class.cursor-pointer]="interferingSettings.length > 0">
Use SPN
</span>
<span *ngIf="!canUse" routerLink="/dashboard" class="cursor-pointer text-tertiary hover:underline">
Get Pro
</span>
<sfng-toggle *ngIf="canUse && spnDisabled === false"
[ngModel]="currentValue" (ngModelChange)="updateUseSpn($event)">
</sfng-toggle>
<span *ngIf="canUse && spnDisabled === true"
routerLink="/spn" class="cursor-pointer text-tertiary hover:underline"
sfng-tooltip="Enable SPN to start using.">
Disabled
</span>
<ng-template *ngIf="canUse && spnDisabled === null">
<fa-icon icon="spinner" [spin]="true"></fa-icon>
</ng-template>
<span class="absolute right-0 block w-2 h-2 bg-yellow-300 border border-gray-100 rounded opacity-75"
style="top: 2px; transform: translateX(-2px)" *ngIf="interferingSettings.length > 0"></span>
</div>
<ng-template #tooltipTemplate>
The following enabled settings may interfere:
<ul class="pl-4 list-disc">
<ng-container *ngFor="let setting of interferingSettings">
<li class="cursor-pointer hover:underline" [routerLink]="[]"
[queryParams]="{setting: setting.Key, tab: 'settings'}">
{{ setting.Name }}
</li>
</ng-container>
</ul>
</ng-template>

View File

@@ -0,0 +1,97 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { BoolSetting, ConfigService, Setting, getActualValue } from "@safing/portmaster-api";
import { SaveSettingEvent } from "src/app/shared/config/generic-setting/generic-setting";
const interferingSettingsWhenOn = [
'spn/usagePolicy'
]
@Component({
selector: 'app-qs-use-spn',
templateUrl: './qs-use-spn.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QuickSettingUseSPNButtonComponent implements OnInit, OnChanges {
private destroyRef = inject(DestroyRef);
@Input()
canUse: boolean = true;
@Input()
settings: Setting[] = [];
@Output()
save = new EventEmitter<SaveSettingEvent>();
currentValue = false
interferingSettings: Setting[] = [];
/* Whether or not the SPN is currently disabled. null means we don't know yet ... */
spnDisabled: boolean | null = null;
constructor(
private configService: ConfigService,
private cdr: ChangeDetectorRef
) { }
ngOnChanges(changes: SimpleChanges): void {
if ('settings' in changes) {
this.currentValue = false;
const useSpnSetting = this.settings.find(s => s.Key === 'spn/use') as (BoolSetting | undefined);
if (!!useSpnSetting) {
this.currentValue = getActualValue(useSpnSetting);
this.updateInterfering();
}
}
}
updateUseSpn(allowed: boolean) {
this.save.next({
isDefault: false,
key: 'spn/use',
value: allowed,
})
}
ngOnInit() {
this.configService.watch<BoolSetting>('spn/enable')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(value => {
this.spnDisabled = !value;
this.cdr.markForCheck();
this.updateInterfering();
})
}
private updateInterfering() {
this.interferingSettings = [];
// only "enabled" state has interfering settings
// only show if we already know if the SPN module is enabled
if (!this.currentValue || this.spnDisabled !== false) {
return
}
// create a lookup map for setting key to setting
const lm = new Map<string, Setting>();
this.settings.forEach(s => lm.set(s.Key, s))
this.interferingSettings = interferingSettingsWhenOn
.map(key => lm.get(key))
.filter(setting => {
if (!setting) {
return false;
}
const value = getActualValue(setting);
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}) as Setting[];
}
}

View File

@@ -0,0 +1,14 @@
<label class="relative" *ngIf="label">
{{ label }}
<div *ngIf="beta" class="absolute top-0 right-0 flex flex-col items-center justify-center gap-0 text-yellow-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="relative z-10 w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
</svg>
<span class="-mt-1 uppercase" style="font-size: 0.6rem">BETA</span>
</div>
</label>
<ng-content></ng-content>

View File

@@ -0,0 +1,30 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { ChangeDetectionStrategy, Component, Input } from "@angular/core";
@Component({
selector: 'app-dashboard-widget',
templateUrl: './dashboard-widget.component.html',
styles: [
`
:host {
@apply bg-gray-200 p-4 self-stretch rounded-md flex flex-col gap-2;
}
label {
@apply text-xs uppercase text-secondary font-light flex flex-row items-center gap-2 pb-2;
}
`
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DashboardWidgetComponent {
@Input()
set beta(v: any) {
this._beta = coerceBooleanProperty(v)
}
get beta() { return this._beta }
private _beta = false;
@Input()
label: string = '';
}

View File

@@ -0,0 +1,281 @@
<div class="w-full gap-4 p-4 dashboard-grid">
<header class="flex flex-row items-center justify-between w-full" id="header">
<div class="flex flex-col flex-grow text-lg font-light text-white">
<h1>
Dashboard
<sfng-tipup key="dashboardIntro" placement="left"></sfng-tipup>
</h1>
<span class="text-sm font-normal text-secondary">
<ng-container *ngIf="!!profile; else: noUsername">
Welcome back, <span class="text-primary">{{ profile.username }}</span>!
<a *ngIf="profile?.state === '' && !!profile?.username" class="text-xs underline cursor-pointer text-tertiary"
(click)="logoutCompletely($event)">Clear</a>
</ng-container>
<ng-template #noUsername>
Welcome back!
</ng-template>
</span>
<span class="mt-2 text-xs font-light text-green-300">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="inline-block h-4 -mt-1 bi bi-caret-left-fill" viewBox="0 0 16 16">
<path
d="m3.86 8.753 5.482 4.796c.646.566 1.658.106 1.658-.753V3.204a1 1 0 0 0-1.659-.753l-5.48 4.796a1 1 0 0 0 0 1.506z" />
</svg>
New: Click shield to open dashboard
</span>
</div>
<div class="flex flex-row gap-8">
<div class="flex flex-col text-xs leading-4">
<span class="font-light text-secondary">
Your current plan is
<span class="font-normal text-primary">
{{ profile?.current_plan?.name || 'Portmaster Free' }}
</span>
</span>
<span class="font-light text-secondary" *ngIf="!profile?.subscription?.next_billing_date">
and ends
<ng-container *ngIf="profile?.subscription?.ends_at; else: endsNever">
in
<span class="font-normal text-primary">
{{ profile?.subscription?.ends_at! | timeAgo }}
</span>
</ng-container>
<ng-template #endsNever>
never
</ng-template>
</span>
<span class="font-light text-secondary" *ngIf="!!profile?.subscription?.next_billing_date">
and auto-renews in <span class="font-normal text-primary">
{{ profile?.subscription?.next_billing_date! | timeAgo }}
</span>
</span>
</div>
<ng-container *ngIf="!!profile && profile.state !== ''; else: loginButton">
<button (click)="openAccountDetails()"
class="text-sm font-normal text-white cursor-pointer btn bg-blue bg-opacity-80 hover:bg-opacity-100 hover:bg-blue">
Account Details
</button>
</ng-container>
<ng-template #loginButton>
<button (click)="openAccountDetails()"
class="text-sm font-normal text-white cursor-pointer btn bg-blue bg-opacity-80 hover:bg-opacity-100 hover:bg-blue">
Login / Subscribe
</button>
</ng-template>
</div>
</header>
<app-dashboard-widget id="features" label="Features">
<div class="feature-card-list">
<app-feature-card *ngFor="let feature of (features$ | async)" [feature]="feature" [disabled]="!feature.enabled">
</app-feature-card>
</div>
</app-dashboard-widget>
<app-dashboard-widget id="stats" label="Recent Activity">
<!-- Mini Stats -->
<div class="mini-stats-list">
<div class="mini-stat" routerLink="/monitor" [queryParams]="{q: 'verdict:3 verdict:4'}">
<label routerLink="/monitor" [queryParams]="{q: 'verdict:3 verdict:4'}">Connections Blocked</label>
<span>{{ blockedConnections }}</span>
</div>
<div class="mini-stat" routerLink="/monitor" [queryParams]="{q: 'active:true'}">
<label routerLink="/monitor" [queryParams]="{q: 'active:true'}">Active Connections</label>
<span>{{ activeConnections }}</span>
</div>
<div class="mini-stat" routerLink="/monitor" [queryParams]="{q: 'active:true groupby:profile'}">
<label routerLink="/monitor" [queryParams]="{q: 'active:true groupby:profile'}">Active Apps</label>
<span>{{ activeProfiles }}</span>
</div>
<div class="mini-stat">
<label>Data Received</label>
<span *ngIf="featureBw">
{{ dataIncoming | bytes }}
</span>
<span *ngIf="!featureBw"
class="!text-xxs !font-light !text-tertiary !text-opacity-50 w-full text-center !leading-3">
Available in<br />Portmaster Plus
</span>
</div>
<div class="mini-stat">
<label>Data Sent</label>
<span *ngIf="featureBw">
{{ dataOutgoing | bytes }}
</span>
<span *ngIf="!featureBw"
class="!text-xxs !font-light !text-tertiary !text-opacity-50 w-full text-center !leading-3">
Available in<br />Portmaster Plus
</span>
</div>
<div class="mini-stat" routerLink="/monitor" [queryParams]="{q: 'tunneled:true groupby:exit_node'}">
<label routerLink="/monitor" [queryParams]="{q: 'tunneled:true groupby:exit_node'}">SPN Identities</label>
<span *ngIf="featureSPN">{{ activeIdentities }}</span>
<span *ngIf="!featureSPN"
class="!text-xxs !font-light !text-tertiary !text-opacity-50 w-full text-center !leading-3">
Available in<br />Portmaster Pro
</span>
</div>
</div>
</app-dashboard-widget>
<app-dashboard-widget id="charts">
<div class="mini-stats-list">
<div class="mini-stat">
<label routerLink="/monitor">
Active/Blocked Connections
</label>
<sfng-netquery-line-chart activeConnectionColor="text-green-300 text-opacity-70" class="w-full !h-36"
[data]="connectionChart"></sfng-netquery-line-chart>
</div>
<div class="mini-stat" *ngIf="featureSPN">
<label routerLink="/monitor" [queryParams]="{q: 'tunneled:true'}">
Connections Tunneled through SPN
</label>
<sfng-netquery-line-chart
[config]="{
series: {
value: {
lineColor: 'text-blue text-opacity-80',
areaColor: 'text-blue text-opacity-20',
}
}
}"
class="w-full !h-36"
[data]="tunneldConnectionChart">
</sfng-netquery-line-chart>
</div>
</div>
</app-dashboard-widget>
<app-dashboard-widget class="flex-grow" id="countries" label="Recent Connections per Country">
<div class="block w-full">
<ul class="list-none auto-grid-4">
<li *ngFor="let country of (connectionsPerCountry | keyvalue); trackBy: trackCountry"
[routerLink]="['/monitor']" [queryParams]="{q: 'country:' + country.key}"
(mouseenter)="onCountryHover(country.key)" (mouseleave)="onCountryHover(null)"
class="flex flex-row items-center p-2 bg-gray-300 rounded-md cursor-pointer hover:bg-gray-400">
<div class="flex flex-row items-center flex-grow gap-2">
<span class="flex-shrink-0" *ngIf="!!country.key" [appCountryFlags]="country.key"></span>
<span
class="overflow-hidden text-xs text-secondary whitespace-nowrap">{{ countryNames[country.key] || country.key || 'N/A' }}</span>
</div>
<span class="ml-2">{{ country.value }}</span>
</li>
</ul>
</div>
</app-dashboard-widget>
<app-dashboard-widget class="flex-grow" id="blocked" label="Recently Blocked Applications">
<div class="block w-full h-full">
<span *ngIf="!blockedProfiles?.length"
class="flex flex-row items-center justify-center h-full gap-2 text-tertiary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<span>
No applications have been blocked in the last 10 minutes.
</span>
</span>
<ul class="list-none auto-grid-3">
<ng-container *ngFor="let entry of blockedProfiles; trackBy: trackApp">
<li *ngIf="(entry.profileID | toAppProfile) as profile" (mouseenter)="onProfileHover(entry.profileID)"
(mouseleave)="onProfileHover(null)" [routerLink]="['/app', profile.Source, profile.ID]"
class="flex flex-row items-center p-2 bg-gray-300 rounded-md cursor-pointer hover:bg-gray-400">
<div class="flex flex-row items-center flex-grow gap-2">
<app-icon [profile]="profile"></app-icon>
<span class="text-xs text-secondary">{{ profile.Name }}</span>
</div>
{{ entry.count }}
</li>
</ng-container>
</ul>
</div>
</app-dashboard-widget>
<app-dashboard-widget class="flex-grow" id="connmap" style="min-height: 400px" label="Recent Connection Countries" beta="true">
<spn-map-renderer class="w-full h-full" mapId="dashboard-map"></spn-map-renderer>
</app-dashboard-widget>
<app-dashboard-widget class="flex-grow" id="bwvis-bar" [ngStyle]="{minHeight: featureBw ? '400px' : 'unset'}" label="Recent Top Consumers" beta="true">
<sfng-netquery-circular-bar-chart *ngIf="featureBw" class="block w-full h-full" [data]="bandwidthBarData" [config]="bandwidthBarConfig"></sfng-netquery-circular-bar-chart>
<span *ngIf="!featureBw"
class="!text-xxs !font-light !text-tertiary !text-opacity-50 w-full text-center !leading-3">
Available in Portmaster Plus
</span>
</app-dashboard-widget>
<app-dashboard-widget class="flex-grow" id="bwvis-line" [ngStyle]="{minHeight: featureBw ? '400px' : 'unset'}" label="Recent Bandwidth Usage" beta="true">
<sfng-netquery-line-chart class="block w-full h-full" *ngIf="featureBw" [data]="bandwidthLineChart" [config]="bwChartConfig"></sfng-netquery-line-chart>
<span *ngIf="!featureBw"
class="!text-xxs !font-light !text-tertiary !text-opacity-50 w-full text-center !leading-3">
Available in Portmaster Plus
</span>
</app-dashboard-widget>
<app-dashboard-widget class="flex-grow relative" id="news" label="News">
<div class="flex flex-col items-center justify-center w-full h-full gap-2 font-light" *ngIf="!news">
<span>News is only available if intel data updates are enabled</span>
<button [routerLink]="['/settings']" [queryParams]="{setting: 'core/automaticIntelUpdates'}">Open Settings</button>
</div>
<div class="flex flex-col items-center justify-center w-full h-full gap-2 font-light" *ngIf="news === 'pending'">
<span>Just a second, we're loading the latest news...</span>
</div>
<ng-container *ngIf="!!news && news !== 'pending'">
<sfng-tab-group linkRouter="false" [customHeader]="true" #carousel>
<sfng-tab *ngFor="let card of news?.cards" [id]="card.title" [title]="card.title">
<section *sfngTabContent class="flex flex-col gap-2 p-2 h-full" (mouseenter)="onCarouselTabHover(card)" (mouseleave)="onCarouselTabHover(null)">
<a [attr.href]="card.url">
<h1 class="flex flex-row gap-2 items-center w-full ml-2 mr-2">
{{ card.title }}
<svg *ngIf="card.url" role="img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="text-white text-opacity-50 w-3 h-3">
<path fill="currentColor" d="M352 0c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9L370.7 96 201.4 265.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L416 141.3l41.4 41.4c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6V32c0-17.7-14.3-32-32-32H352zM80 32C35.8 32 0 67.8 0 112V432c0 44.2 35.8 80 80 80H400c44.2 0 80-35.8 80-80V320c0-17.7-14.3-32-32-32s-32 14.3-32 32V432c0 8.8-7.2 16-16 16H80c-8.8 0-16-7.2-16-16V112c0-8.8 7.2-16 16-16H192c17.7 0 32-14.3 32-32s-14.3-32-32-32H80z"></path>
</svg>
</h1>
</a>
<markdown class="flex-grow" *ngIf="card.body" emoji [data]="card.body"></markdown>
<div *ngIf="card.progress as progress" class="ml-2 mr-2">
<div class="overflow-hidden rounded border bg-gray-400 border-gray-100 h-5 w-full relative">
<div class="h-full" [style]="progress.style" [style.width.%]="progress.percent"></div>
<div class="absolute top-0.5 bottom-0 left-0 right-0 flex flex-row justify-center items-center text-xxs text-background">
<span>{{ progress.percent }}%</span>
</div>
</div>
</div>
<markdown *ngIf="card.footer" emoji [data]="card.footer" class="!text-secondary"></markdown>
</section>
</sfng-tab>
</sfng-tab-group>
<div class="absolute bottom-2 left-0 right-0 flex flex-row items-center justify-center gap-2">
<span *ngFor="let dot of carousel.tabs; let index=index"
class="block w-2 h-2 transition-all duration-150 ease-in-out bg-opacity-50 rounded-full cursor-pointer bg-background"
[class.bg-blue]="carousel.activeTabIndex === index" (click)="carousel.activateTab(index)"></span>
</div>
</ng-container>
</app-dashboard-widget>
</div>

View File

@@ -0,0 +1,166 @@
:host {
@apply flex flex-row w-full gap-3 p-4 overflow-auto;
}
.dashboard-grid {
@apply grid gap-4;
align-items: stretch;
justify-items: stretch;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-template-areas:
"header header header header"
"feature feature feature feature"
"feature feature feature feature"
"stats stats news news"
"stats stats news news"
"charts charts charts charts"
"charts charts charts charts"
"blocked blocked countries countries"
"map map map map"
"bwvis-bar bwvis-bar bwvis-line bwvis-line";
}
:host-context(.min-width-1024px) {
.dashboard-grid {
grid-template-areas:
"header header header header"
"feature feature feature news"
"feature feature feature news"
"stats stats stats news"
"stats stats stats news"
"charts charts charts charts"
"countries countries map map"
"blocked blocked map map"
"bwvis-bar bwvis-bar bwvis-line bwvis-line";
}
}
#header {
grid-area: header;
}
#features {
grid-area: feature;
}
#stats {
grid-area: stats;
}
#charts {
grid-area: charts;
}
#countries {
grid-area: countries;
}
#blocked {
grid-area: blocked;
}
#connmap {
grid-area: map;
}
#bwvis-bar {
grid-area: bwvis-bar;
}
#bwvis-line {
grid-area: bwvis-line;
}
#news {
grid-area: news;
}
.auto-grid-3 {
@apply grid gap-4;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.auto-grid-4 {
@apply grid gap-4;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
app-dashboard-widget {
label {
@apply text-xs uppercase text-secondary font-light flex flex-row items-center gap-2 pb-2;
}
.feature-card-list {
@apply grid gap-3 w-full;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.mini-stats-list {
@apply grid gap-3 w-full;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
&#news {
h1 {
@apply text-base;
@apply font-light;
}
::ng-deep markdown {
@apply font-light;
a {
@apply underline text-blue;
}
strong {
@apply font-medium;
}
}
}
}
::ng-deep #dashboard-map {
#world-group {
--map-bg: #111112;
--map-country-active: #424141;
--map-country-inactive: #2a2a2a;
--map-country-border-width: 1px;
--map-country-border-color: #1e1e1e;
--map-country-border-color-selected: #858585;
--map-country-blocked-primary: #858585;
--map-country-blocked-secondary: #402323;
path {
fill: var(--map-country-active);
stroke: var(--map-bg);
stroke-width: var(--map-country-border-width);
stroke-linejoin: round;
}
path.active {
color: #1d3c24;
fill: currentColor;
}
path.hover {
color: #4fae4f;
fill: currentColor;
}
}
}
.mini-stat {
@apply flex flex-col items-center justify-center py-3 px-2 bg-gray-300 rounded shadow;
label {
@apply font-light uppercase text-xxs text-secondary -mb-2;
}
span {
@apply text-xl text-blue;
}
}

View File

@@ -0,0 +1,481 @@
import { KeyValue } from "@angular/common";
import { AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, OnInit, QueryList, TrackByFunction, ViewChild, ViewChildren, forwardRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { AppProfileService, BandwidthChartResult, ChartResult, Database, FeatureID, Netquery, PortapiService, SPNService, UserProfile, Verdict } from "@safing/portmaster-api";
import { SfngDialogService, SfngTabGroupComponent } from "@safing/ui";
import { Observable, catchError, filter, interval, map, repeat, retry, startWith, throwError } from "rxjs";
import { ActionIndicatorService } from 'src/app/shared/action-indicator';
import { DefaultBandwidthChartConfig, SfngNetqueryLineChartComponent } from "src/app/shared/netquery/line-chart/line-chart";
import { SPNAccountDetailsComponent } from "src/app/shared/spn-account-details";
import { MAP_HANDLER, MapRef } from "../spn/map-renderer";
import { CircularBarChartConfig, splitQueryResult } from "src/app/shared/netquery/circular-bar-chart/circular-bar-chart.component";
import { BytesPipe } from "src/app/shared/pipes/bytes.pipe";
import { HttpErrorResponse } from "@angular/common/http";
interface BlockedProfile {
profileID: string;
count: number;
}
interface BandwidthBarData {
profile: string;
profile_name: string;
series: 'sent' | 'received';
value: number;
sent: number;
received: number;
}
interface NewsCard {
title: string;
body: string;
url?: string;
footer?: string;
progress?: {
percent: number;
style: string;
}
}
interface News {
cards: NewsCard[];
}
const newsResourceIdentifier = "all/intel/portmaster/news.yaml"
@Component({
selector: 'app-dashboard',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./dashboard.component.scss'],
templateUrl: './dashboard.component.html',
providers: [
{ provide: MAP_HANDLER, useExisting: forwardRef(() => DashboardPageComponent), multi: true },
]
})
export class DashboardPageComponent implements OnInit, AfterViewInit {
@ViewChildren(SfngNetqueryLineChartComponent)
lineCharts!: QueryList<SfngNetqueryLineChartComponent>;
@ViewChild(SfngTabGroupComponent)
carouselTabGroup?: SfngTabGroupComponent;
private readonly destroyRef = inject(DestroyRef);
private readonly netquery = inject(Netquery);
private readonly spn = inject(SPNService);
private readonly actionIndicator = inject(ActionIndicatorService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly dialog = inject(SfngDialogService);
private readonly portapi = inject(PortapiService)
resizeObserver!: ResizeObserver;
blockedProfiles: BlockedProfile[] = []
connectionsPerCountry: {
[country: string]: number
} = {};
get countryNames(): { [country: string]: string } {
return this.mapRef?.countryNames || {};
}
bandwidthLineChart: BandwidthChartResult<any>[] = [];
bandwidthBarData: BandwidthBarData[] = [];
readonly bandwidthBarConfig: CircularBarChartConfig<BandwidthBarData> = {
stack: 'profile_name',
seriesKey: 'series',
seriesLabel: d => {
if (d === 'sent') {
return 'Bytes Sent'
}
return 'Bytes Received'
},
value: 'value',
ticks: 3,
colorAsClass: true,
series: {
'sent': {
color: 'text-deepPurple-500 text-opacity-50',
},
'received': {
color: 'text-cyan-800 text-opacity-50',
}
},
formatTick: (tick: number) => {
return new BytesPipe().transform(tick, '1.0-0')
},
formatValue: (stack, series, value, data) => {
const bytes = new BytesPipe().transform
return `${stack}\nSent: ${bytes(data?.sent)}\nReceived: ${bytes(data?.received)}`
},
formatStack: (sel, data) => {
const bytes = new BytesPipe().transform
return sel
.call(sel => {
sel.append("text")
.attr("dy", "0")
.attr("y", "0")
.text(d => d)
})
.call(sel => {
sel.append("text")
.attr("y", 0)
.attr("dy", "0.8rem")
.style("font-size", "0.6rem")
.text(d => {
const first = data.find(result => result.profile_name === d);
return `${bytes(first?.sent)} / ${bytes(first?.received)}`
})
})
}
}
bwChartConfig = DefaultBandwidthChartConfig;
activeConnections: number = 0;
blockedConnections: number = 0;
activeProfiles: number = 0;
activeIdentities = 0;
dataIncoming = 0;
dataOutgoing = 0;
connectionChart: ChartResult[] = [];
tunneldConnectionChart: ChartResult[] = [];
countriesPerProfile: { [profile: string]: string[] } = {}
profile: UserProfile | null = null;
featureBw = false;
featureSPN = false;
hoveredCard: NewsCard | null = null;
features$ = this.spn.watchEnabledFeatures()
.pipe(takeUntilDestroyed());
trackCountry: TrackByFunction<KeyValue<string, any>> = (_, ctr) => ctr.key;
trackApp: TrackByFunction<BlockedProfile> = (_, bp) => bp.profileID;
data: any;
news?: News | 'pending' = 'pending';
private mapRef: MapRef | null = null;
registerMap(ref: MapRef): void {
this.mapRef = ref;
this.mapRef.onMapReady(() => {
this.updateMapCountries();
})
}
private updateMapCountries() {
// this check is basically to make typescript happy ...
if (!this.mapRef) {
return;
}
this.mapRef.worldGroup
.selectAll('path')
.classed('active', (d: any) => {
return !!this.connectionsPerCountry[d.properties.iso_a2];
});
}
unregisterMap(ref: MapRef): void {
this.mapRef = null;
}
onCarouselTabHover(card: NewsCard | null) {
this.hoveredCard = card;
}
openAccountDetails() {
this.dialog.create(SPNAccountDetailsComponent, {
autoclose: true,
backdrop: 'light'
})
}
onCountryHover(code: string | null) {
if (!this.mapRef) {
return
}
this.mapRef.worldGroup
.selectAll('path')
.classed('hover', (d: any) => {
return (d.properties.iso_a2 === code);
});
}
onProfileHover(profile: string | null) {
if (!this.mapRef) {
return
}
this.mapRef.worldGroup
.selectAll('path')
.classed('hover', (d: any) => {
if (!profile) {
return false;
}
return this.countriesPerProfile[profile]?.includes(d.properties.iso_a2);
});
}
ngAfterViewInit(): void {
interval(15000)
.pipe(
takeUntilDestroyed(this.destroyRef),
startWith(-1),
filter(() => this.hoveredCard === null)
)
.subscribe(() => {
if (!this.carouselTabGroup) {
return
}
let next = this.carouselTabGroup.activeTabIndex + 1
if (next >= this.carouselTabGroup.tabs!.length) {
next = 0
}
this.carouselTabGroup.activateTab(next, "left")
})
}
async ngOnInit() {
this.portapi.getResource<News>(newsResourceIdentifier)
.pipe(
repeat({ delay: 60000 }),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: response => {
this.news = response;
this.cdr.markForCheck();
},
error: () => {
this.news = undefined;
this.cdr.markForCheck();
}
});
this.netquery
.batch({
bwBarChart: {
query: {
internal: { $eq: false },
},
select: [
'profile',
'profile_name',
{
$sum: {
field: 'bytes_sent',
as: 'sent'
}
},
{
$sum: {
field: 'bytes_received',
as: 'received'
}
},
],
groupBy: ['profile', 'profile_name'],
},
profileCount: {
select: [
'profile',
{
$count: {
field: '*',
as: 'totalCount'
}
}
],
query: {
verdict: { $in: [Verdict.Block, Verdict.Drop] }
},
groupBy: ['profile'],
databases: [Database.Live]
},
countryStats: {
select: [
'country',
{ $count: { field: '*', as: 'totalCount' } },
{ $sum: { field: 'bytes_sent', as: 'bwout' } },
{ $sum: { field: 'bytes_received', as: 'bwin' } },
],
query: {
allowed: { $eq: true },
},
groupBy: ['country'],
databases: [Database.Live]
},
perCountryConns: {
select: ['profile', 'country', 'active', { $count: { field: '*', as: 'totalCount' } }],
query: {
allowed: { $eq: true },
},
groupBy: ['profile', 'country', 'active'],
databases: [Database.Live],
},
exitNodes: {
query: { tunneled: { $eq: true }, exit_node: { $ne: "" } },
groupBy: ['exit_node'],
select: [
'exit_node',
{ $count: { field: '*', as: 'totalCount' } }
],
databases: [Database.Live],
}
})
.pipe(
repeat({ delay: 10000 }),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(response => {
// bandwidth bar chart
const barChartData = response.bwBarChart
.filter(value => (value.sent + value.received) > 0)
.sort((a, b) => (b.sent + b.received) - (a.sent + a.received))
.slice(0, 10);
this.bandwidthBarData = splitQueryResult(barChartData, ['sent', 'received']) as BandwidthBarData[]
// profileCount
this.blockedConnections = 0;
this.blockedProfiles = [];
response.profileCount?.forEach(row => {
this.blockedConnections += row.totalCount;
this.blockedProfiles.push({
profileID: row.profile!,
count: row.totalCount
})
});
// countryStats
this.connectionsPerCountry = {};
this.dataIncoming = 0;
this.dataOutgoing = 0;
response.countryStats?.forEach(row => {
this.dataIncoming += row.bwin;
this.dataOutgoing += row.bwout;
if (row.country === '') {
return
}
this.connectionsPerCountry[row.country!] = row.totalCount || 0;
})
this.updateMapCountries()
// perCountryConns
let profiles = new Set<string>();
this.activeConnections = 0;
this.countriesPerProfile = {};
response.perCountryConns?.forEach(row => {
profiles.add(row.profile!);
if (row.active) {
this.activeConnections += row.totalCount;
}
const arr = (this.countriesPerProfile[row.profile!] || []);
arr.push(row.country!)
this.countriesPerProfile[row.profile!] = arr;
});
this.activeProfiles = profiles.size;
// exitNodes
this.activeIdentities = response.exitNodes?.length || 0;
this.cdr.markForCheck();
})
// Charts
this.netquery
.activeConnectionChart({})
.pipe(
repeat({ delay: 10000 }),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(result => {
this.connectionChart = result;
this.cdr.markForCheck();
})
this.netquery
.bandwidthChart({}, undefined, 60)
.pipe(
repeat({ delay: 10000 }),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(bw => {
this.bandwidthLineChart = bw;
this.cdr.markForCheck();
})
this.netquery
.activeConnectionChart({ tunneled: { $eq: true } })
.pipe(
repeat({ delay: 10000 }),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(result => {
this.tunneldConnectionChart = result;
this.cdr.markForCheck();
})
// SPN profile and enabled/allowed features
this.spn
.profile$
.pipe(
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: (profile) => {
this.profile = profile || null;
this.featureBw = profile?.current_plan?.feature_ids?.includes(FeatureID.Bandwidth) || false;
this.featureSPN = profile?.current_plan?.feature_ids?.includes(FeatureID.SPN) || false;
// force a full change-detection cylce now!
this.cdr.detectChanges()
// force re-draw of the charts after change-detection because the
// width may change now.
this.lineCharts?.forEach(chart => chart.redraw())
this.cdr.markForCheck();
},
})
}
/** Logs the user out of the SPN completely by purgin the user profile from the local storage */
logoutCompletely(_: Event) {
this.spn.logout(true)
.subscribe(this.actionIndicator.httpObserver(
'Logout',
'You have been logged out of the SPN completely.'
))
}
}

View File

@@ -0,0 +1,61 @@
<div class="feature-card"
[class.disabled]="disabled || comingSoon"
[class.clickable]="disabled || comingSoon || !!configValue || !!feature?.ConfigScope"
(click)="navigateToConfigScope()">
<ng-container *ngIf="disabled || comingSoon">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" class="disabled-bg">
<defs>
<pattern id="pattern_63Hoo" patternUnits="userSpaceOnUse" width="9.5" height="9.5"
patternTransform="rotate(45)">
<line x1="0" y="0" x2="0" y2="9.5" stroke="currentColor" stroke-width="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#pattern_63Hoo)" :opacity="1" />
</svg>
</ng-container>
<header>
<img [attr.src]="feature?.IconURL">
<span>
{{ feature?.Name }}
</span>
<div class="relative flex flex-row self-start flex-grow" *ngIf="disabled || comingSoon || !!feature?.Beta">
<div class="flex-grow"></div>
<div *ngIf="!!feature?.Beta && !disabled"
class="absolute top-0 right-0 flex flex-col items-center justify-center gap-0 text-yellow-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="relative z-10 w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
</svg>
<span class="-mt-1 uppercase" style="font-size: 0.6rem">BETA</span>
</div>
</div>
</header>
<div>
<span class="font-normal ml-7 text-xxs text-secondary">
{{ (disabled ? 'Available in ' : '') + 'Portmaster ' + feature?.InPackage?.Name}}
{{ comingSoon ? ' - coming soon' : '' }}
{{ feature?.Comment }}
</span>
</div>
<div *ngIf="!comingSoon && !disabled && configValue !== undefined" class="absolute right-4 bottom-4">
<sfng-toggle [ngModel]="configValue" (ngModelChange)="updateSettingsValue($event)"
(click)="$event.cancelBubble = true"></sfng-toggle>
</div>
<div *ngIf="!comingSoon && !disabled && configValue === undefined" class="absolute right-4 bottom-4">
<span class="text-light text-green text-opacity-80">
Active
</span>
</div>
<div class="ribbon" *ngIf="!!disabled && !!feature?.InPackage"><span class="ribbon__content"
[style.backgroundColor]="feature?.InPackage?.HexColor" [style.color]="planColor">
{{ feature?.InPackage?.Name }}
</span>
</div>
</div>

View File

@@ -0,0 +1,60 @@
.feature-card {
@apply flex flex-col p-4 bg-gray-300 rounded shadow w-full relative gap-2 overflow-hidden;
.disabled-bg {
@apply absolute top-0 left-0 text-gray-500 opacity-50;
}
&.disabled {
@apply opacity-80 shadow-inner;
}
&.clickable {
@apply cursor-pointer;
&:hover {
@apply bg-gray-400;
}
}
header {
@apply flex flex-row items-center justify-start gap-2 w-full;
img {
@apply w-5 h-5;
filter: invert(1);
}
&>span {
@apply text-base font-light;
}
}
}
.ribbon {
width: 90px;
height: 100%;
overflow: hidden;
position: absolute;
top: 0px;
right: 0px;
z-index: 100;
}
.ribbon__content {
left: -7px;
top: 25px;
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
position: absolute;
display: block;
width: 125px;
padding: 2px 0;
text-shadow: 0 1px 0px rgba(0, 0, 0, .2);
text-transform: uppercase;
text-align: center;
border: 2px dotted #fff;
outline-color: #fff;
outline-width: 1px;
outline-style: solid;
}

View File

@@ -0,0 +1,128 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { Router } from '@angular/router';
import { BoolSetting, ConfigService, Feature } from '@safing/portmaster-api';
import { Subscription } from 'rxjs';
import { INTEGRATION_SERVICE } from 'src/app/integration';
@Component({
selector: 'app-feature-card',
templateUrl: './feature-card.component.html',
styleUrls: ['./feature-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FeatureCardComponent implements OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef);
private readonly configService = inject(ConfigService);
private readonly router = inject(Router);
private readonly integration = inject(INTEGRATION_SERVICE);
private configValueSubscription = Subscription.EMPTY;
@Input()
set disabled(v: any) {
this._disabled = coerceBooleanProperty(v)
}
get disabled() { return this._disabled }
_disabled = false;
get comingSoon() { return this.feature?.ComingSoon || false }
@Input()
feature?: Feature;
planColor: string | null = null;
configValue: boolean | undefined = undefined;
ngOnChanges(changes: SimpleChanges): void {
if ('feature' in changes) {
this.configValueSubscription.unsubscribe();
this.configValueSubscription = Subscription.EMPTY;
if (!!this.feature?.ConfigKey) {
this.configValueSubscription =
this.configService.watch<BoolSetting>(this.feature!.ConfigKey)
.subscribe(value => {
this.configValue = value;
this.cdr.markForCheck();
});
}
if (this.feature?.InPackage?.HexColor) {
this.planColor = getContrastFontColor(this.feature.InPackage.HexColor);
// console.log(this.feature.InPackage.HexColor, this.planColor)
this.cdr.markForCheck();
}
}
}
ngOnDestroy() {
this.configValueSubscription.unsubscribe();
}
updateSettingsValue(newValue: boolean) {
this.configService.save(this.feature!.ConfigKey, newValue)
.subscribe()
}
navigateToConfigScope() {
if (this.disabled) {
this.integration.openExternal("https://safing.io/pricing?source=Portmaster")
return;
}
let key: string | undefined;
if (this.feature?.ConfigScope) {
key = 'config:' + this.feature?.ConfigScope;
} else {
key = this.feature?.ConfigKey;
}
if (!key) {
return
}
this.router.navigate(['/settings'], {
queryParams: {
setting: key,
}
})
}
}
function parseColor(input: string): number[] {
if (input.substr(0, 1) === '#') {
const collen = (input.length - 1) / 3;
const fact = [17, 1, 0.062272][collen - 1];
return [
Math.round(parseInt(input.substr(1, collen), 16) * fact),
Math.round(parseInt(input.substr(1 + collen, collen), 16) * fact),
Math.round(parseInt(input.substr(1 + 2 * collen, collen), 16) * fact),
];
}
return input
.split('(')[1]
.split(')')[0]
.split(',')
.map((x) => +x);
}
function getContrastFontColor(bgColor: string): string {
// if (red*0.299 + green*0.587 + blue*0.114) > 186 use #000000 else use #ffffff
// based on https://stackoverflow.com/a/3943023
let col = bgColor;
if (bgColor.startsWith('#') && bgColor.length > 7) {
col = bgColor.slice(0, 7);
}
const [r, g, b] = parseColor(col);
if (r * 0.299 + g * 0.587 + b * 0.114 > 186) {
return '#000000';
}
return '#ffffff';
}

View File

@@ -0,0 +1 @@
export { MonitorPageComponent } from './monitor';

View File

@@ -0,0 +1,46 @@
<div class="header">
<div class="breadcrumbs">
<span routerLink="/monitor">Network Activity</span>
</div>
<app-expertise></app-expertise>
</div>
<div class="relative flex flex-col flex-grow overflow-auto" cdkScrollable>
<div class="flex flex-col items-start justify-center py-6">
<h1 class="flex flex-row items-center gap-2 text-xl font-semibold text-primary">
Network Activity
<sfng-tipup key="networkMonitor-App-Focus-connection-history"></sfng-tipup>
</h1>
<span class="flex flex-row items-center gap-2 p-0 mb-2 ml-0 text-secondary">
<ng-container *ngIf="(history | async) as data; else: noHistory">
<ng-container *ngIf="!!data">
<span>
Network history data available as of {{ data.first | date }}. ({{ data.count }} connections)
</span>
<a class="text-xs underline cursor-pointer text-primary" (click)="clearHistoryData()">Clear</a>
</ng-container>
</ng-container>
<ng-template #noHistory>
<span>
No network history data available.
<ng-container *ngIf="(canUseHistory | async) && (historyEnabled | async) === false">
<a class="text-xs underline cursor-pointer text-primary" (click)="enableHistory()">Enable</a>
</ng-container>
<ng-container *ngIf="(canUseHistory | async) === false">
<a class="text-xs underline cursor-pointer text-opacity-75" href="https://safing.io/pricing/?source=Portmaster">Available in Portmaster Plus</a>
</ng-container>
</span>
</ng-template>
</span>
<span class="text-secondary">
Use the search bar and drop downs to search and filter the last 10 minutes of network traffic.
Optionally, search all network history data if enabled.
</span>
</div>
<sfng-netquery-viewer [filterPreset]="session.get('monitor/global-filter') || 'scope:4'"
(filterChange)="session.set('monitor/global-filter', $event)"></sfng-netquery-viewer>
</div>

View File

@@ -0,0 +1,49 @@
:host {
overflow: hidden;
flex-direction: row;
flex-grow: 1;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
padding-left: 1.7rem;
padding-right: 0.8rem;
.header,
.content {
padding: 0;
margin: 0;
}
.header {
padding-top: 0.9rem;
.breadcrumbs {
font-size: 0.715rem;
font-weight: 500;
color: #cacaca;
user-select: none;
display: flex;
span:first-child {
opacity: .55;
font-weight: 400;
margin-right: 4px;
&:hover {
opacity: 1;
}
}
svg.arrow {
width: 1rem;
padding: 0;
margin: 0;
.inner {
stroke: white;
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { BoolSetting, ConfigService, Database, FeatureID, Netquery, SPNService } from '@safing/portmaster-api';
import { Subject, interval, map, merge, repeat } from 'rxjs';
import { SessionDataService } from 'src/app/services';
import { ActionIndicatorService } from 'src/app/shared/action-indicator';
import { fadeInAnimation, moveInOutListAnimation } from 'src/app/shared/animations';
@Component({
templateUrl: './monitor.html',
styleUrls: ['../page.scss', './monitor.scss'],
providers: [],
animations: [fadeInAnimation, moveInOutListAnimation],
})
export class MonitorPageComponent {
session = inject(SessionDataService);
netquery = inject(Netquery);
reload = new Subject<void>();
configService = inject(ConfigService);
uai = inject(ActionIndicatorService);
historyEnabled = inject(ConfigService)
.watch<BoolSetting>('history/enable');
canUseHistory = inject(SPNService).profile$
.pipe(
map(profile => {
return profile?.current_plan?.feature_ids?.includes(FeatureID.History) || false;
})
);
history = inject(Netquery)
.query({
select: [
{
$min: {
field: "started",
as: "first_connection",
},
},
{
$count: {
field: "*",
as: "totalCount"
}
}
],
databases: [Database.History]
}, 'monitor-get-first-history-connection')
.pipe(
repeat({ delay: () => merge(interval(10000), this.reload) }),
map(result => {
if (!result.length || result[0].totalCount === 0) {
return null
}
return {
first: new Date(result[0].first_connection),
count: result[0].totalCount,
}
}),
takeUntilDestroyed()
);
enableHistory() {
this.configService.save('history/enable', true)
.subscribe();
}
clearHistoryData() {
this.netquery.cleanProfileHistory([])
.subscribe(() => {
this.reload.next();
})
}
}

View File

@@ -0,0 +1,6 @@
:host {
display : flex;
flex-direction: column;
width : 100%;
height : 100%;
}

View File

@@ -0,0 +1,26 @@
<div class="gap-2 header">
<input type="text" placeholder="Search" [(ngModel)]="searchTerm">
<a href="https://docs.safing.io/portmaster/settings?source=Portmaster"
class="flex flex-row items-center justify-center gap-1 px-2 py-1.5 bg-gray-300 rounded hover:bg-gray-200 text-blue whitespace-nowrap">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
Get Help
</a>
<app-expertise></app-expertise>
</div>
<div class="header-title">
<h1>
Global Settings
<sfng-tipup key="globalSettings"></sfng-tipup>
</h1>
</div>
<app-settings-view [searchTerm]="searchTerm" [highlightKey]="highlightSettingKey" [availableSettings]="settings"
(save)="saveSetting($event)">
</app-settings-view>

View File

@@ -0,0 +1,83 @@
.header-title {
display: flex;
width: 100%;
padding-left: 3rem;
padding-right: 1.25rem;
margin-bottom: 0.5rem;
align-items: center;
height: 3rem;
flex-shrink: 0;
h1{
flex-grow: unset;
}
fa-icon[icon*="question-circle"]{
margin-left: 0.35rem;
}
}
.card-title.meta {
div {
display: inline-block;
@apply mr-2;
}
}
.columns {
width : 100%;
display : flex;
flex-direction: row;
}
.meta {
span:first-of-type {
@apply text-secondary;
@apply mr-1;
}
}
.col {
flex-grow: 1;
}
.unstable {
@apply text-xs;
@apply uppercase;
color: theme('colors.info.yellow');
}
sfng-accordion-group {
@apply pl-12;
@apply pr-4; // align with the scroll bar on the right side
@apply my-4;
}
div.tableFixHead {
@apply mt-4;
@apply rounded-t;
&:not(.empty) {
@apply rounded;
}
max-height: 16rem;
}
.cdk-row.unused {
opacity: 0.4;
}
.card-actions {
display : flex;
align-items: center;
* {
@apply ml-2;
}
app-menu-trigger {
display: inline-block;
}
}

View File

@@ -0,0 +1,133 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ConfigService, Setting } from '@safing/portmaster-api';
import { Subscription } from 'rxjs';
import { StatusService, VersionStatus } from 'src/app/services';
import { ActionIndicatorService } from 'src/app/shared/action-indicator';
import { fadeInAnimation } from 'src/app/shared/animations';
import { SaveSettingEvent } from 'src/app/shared/config/generic-setting/generic-setting';
@Component({
templateUrl: './settings.html',
styleUrls: [
'../page.scss',
'./settings.scss'
],
animations: [fadeInAnimation]
})
export class SettingsComponent implements OnInit, OnDestroy {
/** @private The current search term for the settings. */
searchTerm: string = '';
/** @private All settings currently displayed. */
settings: Setting[] = [];
/** @private The available and selected resource versions. */
versions: VersionStatus | null = null;
/**
* @private
* The key of the setting to highligh, if any ...
*/
highlightSettingKey: string | null = null;
/** Subscription to watch all available settings. */
private subscription = Subscription.EMPTY;
constructor(
public configService: ConfigService,
public statusService: StatusService,
private actionIndicator: ActionIndicatorService,
private route: ActivatedRoute,
) { }
ngOnInit(): void {
this.subscription = new Subscription();
this.loadSettings();
// Request the current resource versions once. We add
// it to the subscription to prevent a memory leak in
// case the user leaves the page before the versions
// have been loaded.
const versionSub = this.statusService.getVersions()
.subscribe(version => this.versions = version);
this.subscription.add(versionSub);
const querySub = this.route.queryParamMap
.subscribe(
params => {
this.highlightSettingKey = params.get('setting');
}
)
this.subscription.add(querySub);
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
/**
* Loads all settings from the portmaster.
*/
private loadSettings() {
const configSub = this.configService.query('')
.subscribe(settings => this.settings = settings);
this.subscription.add(configSub);
}
/**
* @private
* SaveSettingEvent is emitted by the settings-view
* component when a value has been changed and should be saved.
*
* @param event The save-settings event
*/
saveSetting(event: SaveSettingEvent) {
let idx = this.settings.findIndex(setting => setting.Key === event.key);
if (idx < 0) {
return;
}
const setting = {
...this.settings[idx],
}
if (event.isDefault) {
delete (setting['Value']);
} else {
setting.Value = event.value;
}
this.configService.save(setting)
.subscribe({
next: () => {
if (!!event.accepted) {
event.accepted();
}
this.settings[idx] = setting;
// copy the settings into a new array so we trigger
// an input update due to changed array identity.
this.settings = [...this.settings];
// for the release level setting we need to
// to a page-reload since portmaster will now
// return more settings.
if (setting.Key === 'core/releaseLevel') {
this.loadSettings();
}
},
error: err => {
if (!!event.rejected) {
event.rejected(err);
}
this.actionIndicator.error('Failed to save setting', err);
console.error(err);
}
})
}
}

View File

@@ -0,0 +1,154 @@
<h1 class="flex flex-row items-center gap-2" cdkDragHandle cdkDrag cdkDragRootElement=".cdk-overlay-pane">
<span [appCountryFlags]="countryCode"></span>
<span>{{ countryName }}</span>
<span class="flex-grow"></span>
<svg *ngIf="!!dialogRef" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2" class="w-4 h-4 ml-2 opacity-75 cursor-pointer hover:opacity-100" (click)="dialogRef.close()">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</h1>
<sfng-tab-group linkRouter="false" class="h-full">
<!-- Tab that displays all nodes in that country -->
<sfng-tab id="pins" title="Nodes">
<spn-pin-list *sfngTabContent [pins]="pins" (pinHover)="pinHover.next($event)" (pinClick)="openPinDetails($event)"
allowClick="true">
</spn-pin-list>
</sfng-tab>
<!-- Tab that displays generale statistics about the country -->
<sfng-tab id="Statistics" title="Statistics">
<div *sfngTabContent class="flex flex-col gap-3">
<table>
<tr>
<th>
<span class="text-primary">Total Nodes</span>
</th>
<td>{{ totalAliveCount }}</td>
</tr>
<tr *ngIf="totalAliveCount">
<th>
<span class="inline-block pl-4">
<spn-node-icon bySafing="true" isActive="true"></spn-node-icon>
by Safing
</span>
</th>
<td>{{ safingNodeCount }}</td>
</tr>
<tr *ngIf="totalAliveCount">
<th>
<span class="inline-block pl-4">
<spn-node-icon bySafing="false" isActive="true"></spn-node-icon>
by Community
</span>
</th>
<td>{{ communityNodeCount }}</td>
</tr>
<tr>
<th>
<span class="text-primary">Exit Nodes</span>
</th>
<td>{{ exitNodeCount }}</td>
</tr>
<tr *ngIf="!!exitNodeCount">
<th>
<span class="inline-block pl-4">
<spn-node-icon bySafing="true" isExit="true"></spn-node-icon>
by Safing
</span>
</th>
<td>{{ safingExitNodeCount }}</td>
</tr>
<tr *ngIf="!!exitNodeCount">
<th>
<span class="inline-block pl-4">
<spn-node-icon bySafing="false" isExit="true"></spn-node-icon>
by Community
</span>
</th>
<td>{{ communityExitNodeCount }}</td>
</tr>
<tr>
<th>
<span class="text-primary">Nodes In Use</span>
</th>
<td>{{ activeNodeCount }}</td>
</tr>
<tr *ngIf="activeNodeCount">
<th>
<span class="inline-block pl-4">
<spn-node-icon bySafing="true" isActive="true"></spn-node-icon>
by Safing
</span>
</th>
<td>{{ activeSafingNodeCount }}</td>
</tr>
<tr *ngIf="activeNodeCount">
<th>
<span class="inline-block pl-4">
<spn-node-icon bySafing="false" isActive="true"></spn-node-icon>
by Community
</span>
</th>
<td>{{ activeCommunityNodeCount }}</td>
</tr>
</table>
</div>
</sfng-tab>
<!-- Tab that displays all apps that exit in this country -->
<sfng-tab id="profiles" title="Apps">
<div *sfngTabContent>
<span class="inline-block p-2 mb-2 text-tertiary">The following Apps have connections that are routed through the
SPN and use an
exit node in {{ countryName }} ({{ countryCode }}):</span>
<table class="w-full custom ">
<tbody>
<tr *ngFor="let app of profiles; trackBy: trackProfile"
class="bg-transparent hover:bg-gray-500 hover:bg-opacity-50">
<td class="p-2">
<app-icon [profile]="app.profile"></app-icon>
{{ app.profile.Name }}
</td>
<td class="p-2">
{{ app.count }} <span class="text-tertiary">connections</span>
</td>
<td class="w-10 p-2">
<div class="flex flex-row items-center gap-2 ">
<div class="w-6 outline-none cursor-pointer hover:text-primary text-secondary"
[routerLink]="['/app/', app.profile.Source, app.profile.ID]"
[queryParams]="{tab: 0, q: filterConnectionsByCountryNodes}" (click)="$event.stopPropagation()">
<svg viewBox="0 0 24 24" class="w-4 h-4">
<g fill="none" stroke="currentColor">
<path shape-rendering="geometricPrecision" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M8.464 8.464c-1.953 1.953-1.953 5.118 0 7.071 1.953 1.953 5.118 1.953 7.071 0 1.953-1.953 1.953-5.119 0-7.071C14.559 7.488 13.28 7 12 7" />
<path shape-rendering="geometricPrecision" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M5.636 5.636c-3.515 3.515-3.515 9.213 0 12.728 3.515 3.515 9.213 3.515 12.728 0 3.515-3.515 3.515-9.213 0-12.728-2.627-2.627-6.474-3.289-9.717-1.989M5.64 5.64L12 12" />
</g>
</svg>
</div>
<div class="cursor-pointer w-6outline-none hover:text-primary text-secondary"
[routerLink]="['/app/', app.profile.Source, app.profile.ID]" [queryParams]="{tab: 'settings'}"
(click)="$event.stopPropagation()">
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 24 24" class="w-4 h-4"
fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" fill="currentColor"
d="M19 21h-3a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2Z" />
<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9h-3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2ZM5 3h3a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2ZM5 15h3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2Z" />
</svg>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</sfng-tab>
</sfng-tab-group>

View File

@@ -0,0 +1,217 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Inject, Input, OnChanges, OnDestroy, OnInit, Optional, Output, SimpleChanges, TrackByFunction } from "@angular/core";
import { AppProfile, AppProfileService, Netquery } from '@safing/portmaster-api';
import { SFNG_DIALOG_REF, SfngDialogRef, SfngDialogService } from "@safing/ui";
import { Subscription, forkJoin, of, switchMap } from 'rxjs';
import { repeat } from 'rxjs/operators';
import { MapPin, MapService } from './../map.service';
import { PinDetailsComponent } from './../pin-details/pin-details';
@Component({
templateUrl: './country-details.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`:host{
display: block;
min-width: 630px;
height: 400px;
overflow: hidden;
}`
]
})
export class CountryDetailsComponent implements OnInit, OnChanges, OnDestroy {
/** Subscription to poll map pins and profiles. */
private subscription = Subscription.EMPTY;
/** The two letter ISO country code */
@Input()
countryCode: string = '';
/** The name of the country */
@Input()
countryName: string = '';
/** Emits the ID of the pin that is hovered in the list. null if no pin is hovered */
@Output()
pinHover = new EventEmitter<string | null>();
/** @private - The list of pins available in this country */
pins: MapPin[] = [];
/** @private - A list of app profiles that use this country as an exit node */
profiles: { profile: AppProfile, count: number }[] = [];
/** @private - A {@link TrackByFunction} for all profiles that use this country for exit */
trackProfile: TrackByFunction<this['profiles'][0]> = (_: number, profile: this['profiles'][0]) => `${profile.profile.Source}/${profile.profile.ID}`;
/** The number of alive nodes in this country */
totalAliveCount = 0;
/** The number of exit nodes in this country */
exitNodeCount = 0;
/** The number of active (used) nodes in this country */
activeNodeCount = 0;
/** The number of active (used) nodes operated by safing */
activeSafingNodeCount = 0;
/** The number of active (used) nodes operated by the community */
activeCommunityNodeCount = 0;
/** The number of nodes operated by safing */
safingNodeCount = 0;
/** The number of exit nodes operated by safing */
safingExitNodeCount = 0;
/** The number of nodes operated by a community member */
communityNodeCount = 0;
/** The number of exit ndoes operated by the community */
communityExitNodeCount = 0;
/** holds the text format of a netquery search to show all connections that exit in this country */
filterConnectionsByCountryNodes = '';
constructor(
private mapService: MapService,
private netquery: Netquery,
private appService: AppProfileService,
private cdr: ChangeDetectorRef,
private dialog: SfngDialogService,
@Inject(SFNG_DIALOG_REF) @Optional() public dialogRef?: SfngDialogRef<CountryDetailsComponent, never, { code: string, name: string }>,
) { }
openPinDetails(id: string) {
this.dialog.create(PinDetailsComponent, {
data: id,
backdrop: false,
autoclose: true,
dragable: true,
})
}
ngOnInit() {
// if we got opened as a dialog we get the code and name of the country
// from the dialogRef.data field.
if (!!this.dialogRef) {
this.countryCode = this.dialogRef.data.code;
this.countryName = this.dialogRef.data.name;
}
this.subscription.unsubscribe();
this.subscription =
this.mapService
.pins$
.pipe(
switchMap(pins => {
// get a list of pins in that country
const countryPins = pins.filter(pin => pin.entity.Country === this.countryCode);
// prepare a netquery query that loads the IDs of all profiles that use one of the countries
// pins as an exit node. Then, map those IDs to the actual app profile object
const profiles = this.netquery
.query({
select: [
'profile',
{ $count: { field: '*', as: 'totalCount' } }
],
groupBy: ['profile'],
query: {
'exit_node': {
$in: countryPins.map(pin => pin.pin.ID),
}
}
}, 'get-connections-per-profile-in-country')
.pipe(
switchMap(queryResult => {
if (queryResult.length === 0) {
return of([]);
}
return forkJoin(
queryResult.map(row => forkJoin({
profile: this.appService.getAppProfile(row.profile!),
count: of(row.totalCount),
})
)
)
}),
);
return forkJoin({
pins: of(countryPins),
profiles: profiles,
})
}
),
repeat({
delay: 5000
}),
)
.subscribe(result => {
this.pins = result.pins;
this.profiles = result.profiles
this.activeNodeCount = 0;
this.activeCommunityNodeCount = 0;
this.activeSafingNodeCount = 0;
this.exitNodeCount = 0;
this.safingNodeCount = 0;
this.communityNodeCount = 0;
this.safingExitNodeCount = 0;
this.communityExitNodeCount = 0;
this.pins.forEach(pin => {
if (pin.isOffline) {
return
}
this.totalAliveCount++;
if (pin.pin.VerifiedOwner === 'Safing') {
this.safingNodeCount++;
if (pin.isExit) {
this.exitNodeCount++;
this.safingExitNodeCount++;
}
if (pin.isActive) {
this.activeSafingNodeCount++;
this.activeNodeCount++;
}
} else {
this.communityNodeCount++;
if (pin.isExit) {
this.exitNodeCount++;
this.communityExitNodeCount++;
}
if (pin.isActive) {
this.activeCommunityNodeCount++;
this.activeNodeCount++;
}
}
})
// create a netquery text-query in the format of "exit_node:<id1> exit_node:<id2> ..."
this.filterConnectionsByCountryNodes = this.pins.map(pin => `exit_node:${pin.pin.ID}`).join(" ")
this.cdr.markForCheck();
})
}
ngOnChanges(changes: SimpleChanges): void {
// if we are rendered as a regular component (not as a dialog) we need to
// handle updates to our @Inputs().
// just let ngOnInit() do it's thing if the countryCode changed.
if (!!changes['countryCode']) {
this.ngOnInit();
}
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}

View File

@@ -0,0 +1 @@
export * from './country-details';

View File

@@ -0,0 +1,25 @@
<span class="country-content-wrapper">
<span class="country-name">
<span class="country-name-flag" [appCountryFlags]="countryCode"></span>
<span class="country-name-name">{{ countryName }}</span>
</span>
<span class="country-stats">
<span class="country-stats--safing">
<spn-node-icon bySafing="true" isActive="true"></spn-node-icon>
<span>Safing Nodes:</span>
<span class="count">{{ safingNodes.length }}</span>
</span>
<span class="country-stats--community">
<spn-node-icon bySafing="false" isActive="true"></spn-node-icon>
<span>Community Nodes:</span>
<span class="count">{{ communityNodes.length }}</span>
</span>
</span>
<span class="pb-2 text-xxs text-tertiary">
Click country for details
</span>
</span>

View File

@@ -0,0 +1,40 @@
:host {
@apply flex flex-row items-center justify-center;
}
.country-content-wrapper {
@apply flex flex-col gap-2 items-center justify-center bg-gray-200 border bg-opacity-50 border-gray-600 border-opacity-25;
}
.country-name {
@apply text-sm flex flex-row gap-1 items-center justify-center bg-gray-100 bg-opacity-50 py-2 w-full;
}
.country-stats {
@apply flex flex-col gap-2 items-start py-2 px-4;
&>span {
@apply flex flex-row gap-1 items-center;
@apply text-xs font-light;
}
.count {
@apply text-sm font-normal;
}
}
.country-stats--safing {
svg polygon {
fill: #0376bb;
stroke: #0376bb;
transform: scale(1.15)
}
}
.country-stats--community {
svg circle {
fill: #239215;
stroke: #239215;
transform: scale(1.15)
}
}

View File

@@ -0,0 +1,75 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { takeWhile } from 'rxjs/operators';
import { MapPin, MapService } from './../map.service';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'spn-map-country-overlay',
templateUrl: './country-overlay.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: [
'./country-overlay.scss'
]
})
export class CountryOverlayComponent implements OnInit, OnChanges, OnDestroy {
/** The two-letter ISO code of the country */
@Input()
countryCode!: string;
/** The (english) name of the country */
@Input()
countryName!: string;
/** all nodes in this country operated by Safing */
safingNodes: MapPin[] = [];
/** all nodes in this country operated by a community member */
communityNodes: MapPin[] = [];
/** used to trigger a reload onChanges */
private reload$ = new BehaviorSubject<void>(undefined);
constructor(
private mapService: MapService,
private cdr: ChangeDetectorRef,
) { }
ngOnChanges(changes: SimpleChanges): void {
this.reload$.next();
}
ngOnInit(): void {
combineLatest([
this.mapService.pins$,
this.reload$
])
.pipe(
takeWhile(() => !this.reload$.closed),
map(([pins]) => pins.filter(pin => pin.entity.Country === this.countryCode)),
)
.subscribe(pinsInCountry => {
this.safingNodes = [];
this.communityNodes = [];
pinsInCountry.forEach(pin => {
if (pin.isOffline && !pin.isActive) {
return
}
if (pin.pin.VerifiedOwner === 'Safing') {
this.safingNodes.push(pin)
} else {
this.communityNodes.push(pin)
}
})
this.cdr.markForCheck();
})
}
ngOnDestroy(): void {
this.reload$.complete();
}
}

View File

@@ -0,0 +1 @@
export * from './country-overlay';

View File

@@ -0,0 +1 @@
export * from './spn-page';

View File

@@ -0,0 +1 @@
export * from './map-legend';

View File

@@ -0,0 +1,54 @@
<div class="flex flex-col gap-2 bg-gray-200 bg-opacity-50 border border-gray-600 border-opacity-25">
<table class="p-2 font-thin custom">
<tr>
<td class="p-2 font-normal">
<spn-node-icon bySafing="true"></spn-node-icon>
Safing Nodes
</td>
<td class="p-2">{{ safingNodeCount }}</td>
</tr>
<tr>
<td class="p-2">
<span class="pl-5">
<spn-node-icon bySafing="true" isActive="true"></spn-node-icon>
used as Transit
</span>
</td>
<td class="p-2">{{ safingActiveCount }}</td>
</tr>
<tr>
<td class="p-2">
<span class="pl-5">
<spn-node-icon bySafing="true" isExit="true"></spn-node-icon>
used as Exit
</span>
</td>
<td class="p-2">{{ safingExitCount }}</td>
</tr>
<tr>
<td class="p-2 font-normal">
<spn-node-icon bySafing="false"></spn-node-icon>
Community Nodes
</td>
<td class="p-2">{{ communityNodeCount }}</td>
</tr>
<tr>
<td class="p-2">
<span class="pl-5">
<spn-node-icon bySafing="false" isActive="true"></spn-node-icon>
used as Transit
</span>
</td>
<td class="p-2">{{ communityActiveCount }}</td>
</tr>
<tr>
<td class="p-2">
<span class="pl-5">
<spn-node-icon bySafing="false" isExit="true"></spn-node-icon>
used as Exit
</span>
</td>
<td class="p-2">{{ communityExitCount }}</td>
</tr>
</table>
</div>

View File

@@ -0,0 +1,69 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
import { Subscription } from 'rxjs';
import { MapService } from './../map.service';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'spn-map-legend',
templateUrl: './map-legend.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SpnMapLegendComponent implements OnInit, OnDestroy {
private subscription = Subscription.EMPTY;
safingNodeCount = 0;
safingExitCount = 0;
safingActiveCount = 0;
communityNodeCount = 0;
communityExitCount = 0;
communityActiveCount = 0;
constructor(
private mapService: MapService,
private cdr: ChangeDetectorRef,
) { }
ngOnInit() {
this.subscription = this.mapService
.pins$
.subscribe(pins => {
this.safingActiveCount = 0;
this.safingExitCount = 0;
this.safingNodeCount = 0;
this.communityActiveCount = 0;
this.communityExitCount = 0;
this.communityNodeCount = 0;
pins.forEach(pin => {
if (pin.pin.VerifiedOwner === 'Safing') {
if (pin.isActive) {
this.safingActiveCount++;
}
if (pin.isExit) {
this.safingExitCount++
}
this.safingNodeCount++
} else {
if (pin.isActive) {
this.communityActiveCount++;
}
if (pin.isExit) {
this.communityExitCount++;
}
this.communityNodeCount++;
}
})
this.cdr.markForCheck();
})
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}

View File

@@ -0,0 +1 @@
export * from './map-renderer';

View File

@@ -0,0 +1,383 @@
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, Inject, InjectionToken, Input, OnDestroy, OnInit, Optional, inject } from '@angular/core';
import { GeoPath, GeoPermissibleObjects, GeoProjection, Selection, ZoomTransform, geoMercator, geoPath, json, pointer, select, zoom, zoomIdentity } from 'd3';
import { feature } from 'topojson-client';
export type MapRoot = Selection<SVGSVGElement, unknown, null, never>;
export type WorldGroup = Selection<SVGGElement, unknown, null, unknown>
export interface CountryEvent {
event?: MouseEvent;
countryCode: string;
countryName: string;
}
export interface MapRef {
onMapReady(cb: () => any): void;
onZoomPan(cb: () => any): void;
onCountryHover(cb: (_: CountryEvent | null) => void): void;
onCountryClick(cb: (_: CountryEvent) => void): void;
select(selection: string): Selection<any, any, any, any> | null;
countryNames: { [key: string]: string };
root: MapRoot;
projection: GeoProjection;
zoomScale: number;
worldGroup: WorldGroup;
}
export interface MapHandler {
registerMap(ref: MapRef): void;
unregisterMap(ref: MapRef): void;
}
export const MAP_HANDLER = new InjectionToken<MapHandler>('MAP_HANDLER');
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'spn-map-renderer',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '',
styleUrls: [
'./map-style.scss'
],
})
export class MapRendererComponent implements OnInit, AfterViewInit, OnDestroy {
static readonly Rotate = 0; // so [-0, 0] is the initial center of the projection
static readonly Maxlat = 83; // clip northern and southern pols (infinite in mercator)
static readonly MarkerSize = 4;
static readonly LineAnimationDuration = 200;
private readonly destroyRef = inject(DestroyRef);
private destroyed = false;
countryNames: {
[countryCode: string]: string
} = {}
// SVG group elements
private svg: MapRoot | null = null;
worldGroup!: WorldGroup;
// Projection and line rendering functions
projection!: GeoProjection;
zoomScale: number = 1
private pathFunc!: GeoPath<any, GeoPermissibleObjects>;
get root() {
return this.svg!
}
@Input()
mapId: string = 'map'
constructor(
private mapRoot: ElementRef<HTMLElement>,
private cdr: ChangeDetectorRef,
@Inject(MAP_HANDLER) @Optional() private overlays: MapHandler[],
) { }
ngOnInit(): void {
this.overlays?.forEach(ov => {
ov.registerMap(this)
})
this.cdr.detach()
}
select(selector: string) {
if (!this.svg) {
return null
}
return this.svg.select(selector);
}
private _readyCb: (() => void)[] = [];
onMapReady(cb: () => void) {
this._readyCb.push(cb);
}
private _zoomCb: (() => void)[] = [];
onZoomPan(cb: () => void) {
this._zoomCb.push(cb)
}
private _countryHoverCb: ((e: CountryEvent | null) => void)[] = [];
onCountryHover(cb: (e: CountryEvent | null) => void) {
this._countryHoverCb.push(cb);
}
private _countryClickCb: ((e: CountryEvent) => void)[] = [];
onCountryClick(cb: (e: CountryEvent) => void) {
this._countryClickCb.push(cb)
}
async ngAfterViewInit() {
await this.renderMap()
const observer = new ResizeObserver(() => {
this.renderMap()
})
this.destroyRef.onDestroy(() => {
observer.unobserve(this.mapRoot.nativeElement)
observer.disconnect()
})
observer.observe(this.mapRoot.nativeElement);
}
async renderMap() {
if (this.destroyed) {
return;
}
if (!!this.svg) {
this.svg.remove()
}
const map = select(this.mapRoot.nativeElement);
// setup the basic SVG elements
this.svg = map
.append('svg')
.attr('id', this.mapId)
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr('width', '100%')
.attr('preserveAspectRation', 'none')
.attr('height', '100%')
this.worldGroup = this.svg.append('g').attr('id', 'world-group')
// load the world-map data and start rendering
const world = await json<any>('/assets/world-50m.json');
// actually render the countries
const countries = (feature(world, world.objects.countries) as any);
this.setupProjection();
await this.setupZoom(countries);
// we need to await the initial world render here because otherwise
// the initial renderPins() will not be able to update the country attributes
// and cause a delay before the state of the country (has-nodes, is-blocked, ...)
// is visible.
this.renderWorld(countries);
this._readyCb.forEach(cb => cb());
}
ngOnDestroy() {
this.destroyed = true;
this.overlays?.forEach(ov => ov.unregisterMap(this));
this._countryClickCb = [];
this._countryHoverCb = [];
this._readyCb = [];
this._zoomCb = [];
if (!this.svg) {
return;
}
this.svg.remove();
this.svg = null;
}
private renderWorld(countries: any) {
// actually render the countries
const data = countries.features;
const self = this;
data.forEach((country: any) => {
this.countryNames[country.properties.iso_a2] = country.properties.name
})
this.worldGroup.selectAll()
.data<GeoPermissibleObjects>(data)
.enter()
.append('path')
.attr('countryCode', (d: any) => d.properties.iso_a2)
.attr('name', (d: any) => d.properties.name)
.attr('d', this.pathFunc)
.on('mouseenter', function (event: MouseEvent) {
const country = select(this).datum() as any;
const countryEvent: CountryEvent = {
event: event,
countryCode: country.properties.iso_a2,
countryName: country.properties.name,
}
self._countryHoverCb.forEach(cb => cb(countryEvent))
})
.on('mouseout', function (event: MouseEvent) {
self._countryHoverCb.forEach(cb => cb(null))
})
.on('click', function (event: MouseEvent) {
const country = select(this).datum() as any;
const countryEvent: CountryEvent = {
event: event,
countryCode: country.properties.iso_a2,
countryName: country.properties.name,
}
const loc = self.projection.invert!([event.clientX, event.clientY])
console.log(loc)
self._countryClickCb.forEach(cb => cb(countryEvent))
})
}
private setupProjection() {
const size = this.mapRoot.nativeElement.getBoundingClientRect();
this.projection = geoMercator()
.rotate([MapRendererComponent.Rotate, 0])
.scale(1)
.translate([size.width / 2, size.height / 2]);
// path is used to update the SVG path to match our mercator projection
this.pathFunc = geoPath().projection(this.projection);
}
private async setupZoom(countries: any) {
if (!this.svg) {
return
}
// create a copy of countries
countries = {
...countries,
features: [...countries.features]
}
// remove Antarctica from the feature set so projection.fitSize ignores it
// and better aligns the rest of the world :)
const aqIdx = countries.features.findIndex((p: GeoJSON.Feature) => p.properties?.iso_a2 === "AQ");
if (aqIdx >= 0) {
countries.features.splice(aqIdx, 1)
}
const size = this.mapRoot.nativeElement.getBoundingClientRect();
this.projection.fitSize([size.width, size.height], countries)
//this.projection.fitWidth(size.width, countries)
//this.projection.fitHeight(size.height, countries)
// returns the top-left and the bottom-right of the current projection
const mercatorBounds = () => {
const yaw = this.projection.rotate()[0];
const xymax = this.projection([-yaw + 180 - 1e-6, -MapRendererComponent.Maxlat])!;
const xymin = this.projection([-yaw - 180 + 1e-6, MapRendererComponent.Maxlat])!;
return [xymin, xymax];
}
const s = this.projection.scale()
const scaleExtent = [s, s * 10]
const transform = zoomIdentity
.scale(this.projection.scale())
.translate(this.projection.translate()[0], this.projection.translate()[1]);
// whenever the users zooms we need to update our groups
// individually to apply the zoom effect.
let tlast = {
x: 0,
y: 0,
k: 0,
}
const self = this;
let z = zoom<SVGSVGElement, unknown>()
.scaleExtent(scaleExtent as [number, number])
.on('zoom', (e) => {
const t: ZoomTransform = e.transform;
if (t.k != tlast.k) {
let p = pointer(e)
let scrollToMouse = () => { };
if (!!p && !!p[0]) {
const tp = this.projection.translate();
const coords = this.projection!.invert!(p)
scrollToMouse = () => {
const newPos = this.projection(coords!)!;
const yaw = this.projection.rotate()[0];
this.projection.translate([tp[0], tp[1] + (p[1] - newPos[1])])
this.projection.rotate([yaw + 360.0 * (p[0] - newPos[0]) / size.width * scaleExtent[0] / t.k, 0, 0])
}
}
this.projection.scale(t.k);
scrollToMouse();
} else {
let dy = t.y - tlast.y;
const dx = t.x - tlast.x;
const yaw = this.projection.rotate()[0]
const tp = this.projection.translate();
// use x translation to rotate based on current scale
this.projection.rotate([yaw + 360.0 * dx / size.width * scaleExtent[0] / t.k, 0, 0])
// use y translation to translate projection clamped to bounds
let bounds = mercatorBounds();
if (bounds[0][1] + dy > 0) {
dy = -bounds[0][1];
} else if (bounds[1][1] + dy < size.height) {
dy = size.height - bounds[1][1];
}
this.projection.translate([tp[0], tp[1] + dy]);
}
tlast = {
x: t.x,
y: t.y,
k: t.k,
}
// finally, re-render the SVG shapes according to the new projection
this.worldGroup.selectAll<SVGPathElement, GeoPermissibleObjects>('path')
.attr('d', this.pathFunc)
this._zoomCb.forEach(cb => cb());
});
this.svg.call(z)
this.svg.call(z.transform, transform);
}
public getCoords(lat: number, lng: number) {
const loc = this.projection([lng, lat]);
if (!loc) {
return null;
}
const rootElem = this.mapRoot.nativeElement.getBoundingClientRect();
const x = rootElem.x + loc[0];
const y = rootElem.y + loc[1];
return [x, y];
}
public coordsInView(lat: number, lng: number) {
const loc = this.projection([lng, lat]);
if (!loc) {
return false
}
const rootElem = this.mapRoot.nativeElement.getBoundingClientRect();
const x = rootElem.x + loc[0];
const y = rootElem.y + loc[1];
return x >= rootElem.left && x <= rootElem.right && y >= rootElem.top && y <= rootElem.bottom;
}
}

View File

@@ -0,0 +1,167 @@
::ng-deep {
.pin {
opacity: 0;
&.in-view {
opacity: 1;
}
}
}
::ng-deep #spn-map {
--map-bg: #111112;
--map-country-active: #424141;
--map-country-inactive: #2a2a2a;
--map-country-border-width: 2px;
--map-country-border-color: #1e1e1e;
--map-country-border-color-selected: #858585;
--map-country-blocked-primary: #858585;
--map-country-blocked-secondary: #402323;
.overlay {
fill: none;
pointer-events: all;
}
g {
circle,
polygon {
fill: #626262;
stroke: #626262;
stroke-width: 1;
stroke-linejoin: round;
transition: all 200ms linear 0s;
}
circle:hover,
polygon:hover {
fill: theme('colors.yellow.200');
stroke: theme('colors.yellow.300');
stroke-width: 2;
}
}
g[in-use=true] {
circle {
fill: #239215;
stroke: #239215;
transform: scale(1.15)
}
polygon {
fill: #0376bb;
stroke: #0376bb;
transform: scale(1.15)
}
}
g[is-exit=true] {
circle,
polygon {
transform: scale(1.3);
stroke-width: 2;
}
polygon {
stroke: #039af4;
fill: #0376bb;
}
circle {
stroke: #30ae20;
fill: #239215;
}
}
g[is-home=true] circle {
stroke: white;
stroke-width: 4.5;
fill: black;
transform: scale(1);
}
g[raise=true] {
circle,
polygon {
fill: theme('colors.yellow.200');
stroke: theme('colors.yellow.300');
stroke-width: 2;
transform: scale(1.8);
}
}
.marker {
cursor: pointer;
fill: #252525;
stroke: rgba(151, 151, 151, 0.8);
transition: all 250ms 0s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.marker-label {
fill: white;
}
path.lane {
stroke: rgba(151, 151, 151, 0.2);
fill: transparent;
&[in-use=true] {
stroke-width: 2;
stroke: #0376bb;
}
&[is-live=true] {
stroke-width: 1;
stroke: theme('colors.red.300');
&[is-encrypted=true] {
stroke: theme('colors.green.200');
}
&:hover {
stroke-width: 3;
}
}
}
#world-group {
path {
fill: var(--map-country-border-color);
stroke: var(--map-country-border-color);
stroke-width: var(--map-country-border-width);
stroke-linejoin: round;
}
path[has-nodes=true] {
fill: var(--map-country-inactive);
}
path[in-use=true] {
fill: var(--map-country-active);
}
path:hover {
cursor: pointer;
fill: var(--map-country-active);
}
path.selected {
stroke: var(--map-country-border-color-selected);
}
}
}
:host-context(.disabled) {
@apply bg-white;
#world-group {
path {
fill: #000000;
stroke: #111111;
stroke-width: .5px;
}
}
}

View File

@@ -0,0 +1,253 @@
import { Injectable } from '@angular/core';
import { AppProfile, GeoCoordinates, IntelEntity, Netquery, Pin, SPNService, UnknownLocation, getPinCoords } from '@safing/portmaster-api';
import { BehaviorSubject, Observable, combineLatest, debounceTime, interval, of, startWith, switchMap } from 'rxjs';
import { distinctUntilChanged, filter, map, share } from 'rxjs/operators';
import { SPNStatus } from './../../../../projects/safing/portmaster-api/src/lib/spn.types';
export interface MapPin {
pin: Pin;
// location is set to the geo-coordinates that should be used
// for that pin.
location: GeoCoordinates;
// entity is set to the intel entity that should be used for
// this pin.
entity: IntelEntity;
// whether the pin is regarded as offline / not available.
isOffline: boolean;
// whether or not the pin is currently used as an exit node
isExit: boolean;
// whether or not the pin is used as a transit node
isTransit: boolean;
// whether or not the pin is currently active.
isActive: boolean;
// whether or not the pin is used as the entry-node.
isHome: boolean;
// whether the pin has any known issues
hasIssues: boolean;
// FIXME: remove me
collapsed?: boolean;
}
@Injectable({ providedIn: 'root' })
export class MapService {
/**
* activeSince$ emits the pre-formatted duration since the SPN is active
* it formats the duration as "HH:MM:SS" or null if the SPN is not enabled.
*/
activeSince$: Observable<string | null>;
/** Emits the current status of the SPN */
status$: Observable<SPNStatus['Status']>;
/** Emits all map pins */
_pins$ = new BehaviorSubject<MapPin[]>([]);
get pins$(): Observable<MapPin[]> {
return this._pins$.asObservable();
}
pinsMap$ = this.pins$
.pipe(
filter(allPins => !!allPins.length),
map(allPins => {
const lm = new Map<string, MapPin>();
allPins.forEach(pin => lm.set(pin.pin.ID, pin));
return lm
}),
share(),
)
constructor(
private spnService: SPNService,
private netquery: Netquery,
) {
this.status$ = this.spnService
.status$
.pipe(
map(status => !!status ? status.Status : 'disabled'),
distinctUntilChanged()
);
// setup the activeSince$ observable that emits every second how long the
// SPN has been active.
this.activeSince$ = combineLatest([
this.spnService.status$,
interval(1000).pipe(startWith(-1))
]).pipe(
map(([status]) => !!status.ConnectedSince ? this.formatActiveSinceDate(status.ConnectedSince) : null),
share(),
);
let pinMap = new Map<string, MapPin>();
let pinResult: MapPin[] = [];
// create a stream of pin updates from the SPN service if it is enabled.
this.status$
.pipe(
switchMap(status => {
if (status !== 'disabled') {
return combineLatest([
this.spnService.watchPins(),
interval(5000)
.pipe(
startWith(-1),
switchMap(() => this.getPinIDsUsedAsExit())
)
])
}
return of([[], []]);
}),
map(([pins, exitPinIDs]) => {
const exitPins = new Set(exitPinIDs);
const activePins = new Set<string>();
const transitPins = new Set<string>();
const seenPinIDs = new Set<string>();
let hasChanges = false;
pins.forEach(pin => pin.Route?.forEach((hop, index) => {
if (index < pin.Route!.length - 1) {
transitPins.add(hop)
}
activePins.add(hop);
}));
pins.forEach(pin => {
// Save Pin ID as seen.
seenPinIDs.add(pin.ID);
const oldPinModel = pinMap.get(pin.ID);
// Get states of new model.
const isOffline = pin.States.includes('Offline') || !pin.States.includes('Reachable');
const isHome = pin.HopDistance === 1;
const isTransit = transitPins.has(pin.ID);
const isExit = exitPins.has(pin.ID);
const isActive = activePins.has(pin.ID);
const hasIssues = pin.States.includes('ConnectivityIssues');
const pinHasChanged = !oldPinModel || oldPinModel.pin !== pin ||
oldPinModel.isOffline !== isOffline || oldPinModel.isHome !== isHome || oldPinModel.isTransit !== isTransit ||
oldPinModel.isExit !== isExit || oldPinModel.isActive !== isActive || oldPinModel.hasIssues !== hasIssues;
if (pinHasChanged) {
const newPinModel: MapPin = {
pin: pin,
location: getPinCoords(pin) || UnknownLocation,
entity: (pin.EntityV4 || pin.EntityV6)!,
isExit,
isTransit,
isActive,
isOffline,
isHome,
hasIssues,
}
pinMap.set(pin.ID, newPinModel);
hasChanges = true;
}
})
for (let key of pinMap.keys()) {
if (!seenPinIDs.has(key)) {
// this pin has been removed
pinMap.delete(key)
hasChanges = true;
}
}
if (hasChanges) {
pinResult = Array.from(pinMap.values());
}
return pinResult;
}),
debounceTime(10),
distinctUntilChanged(),
)
.subscribe(pins => this._pins$.next(pins))
}
getExitPinIDsForProfile(profile: AppProfile) {
return this.netquery
.query({
select: ['exit_node'],
groupBy: ['exit_node'],
query: {
profile: { $eq: `${profile.Source}/${profile.ID}` },
}
}, 'map-service-get-exit-pin-ids-for-profile')
.pipe(map(result => result.map(row => row.exit_node!)))
}
getPinIDsWithActiveSession() {
return this.pins$
.pipe(
map(result => result.filter(pin => pin.pin.SessionActive).map(pin => pin.pin.ID))
)
}
getPinIDsUsedAsExit() {
return this.netquery
.query({
select: ['exit_node'],
groupBy: ['exit_node']
}, 'map-service-get-pins-used-as-exit')
.pipe(
map(result => result.map(row => row.exit_node!))
)
}
getPinIDsWithActiveConnections() {
return this.netquery.query({
select: ['exit_node'],
groupBy: ['exit_node'],
query: {
active: { $eq: true }
}
}, 'map-service-get-pins-with-connections')
.pipe(
map(activeExitNodes => {
const pins = this._pins$.getValue();
const pinIDs = new Set<string>();
const pinLookupMap = new Map<string, MapPin>();
pins.forEach(p => pinLookupMap.set(p.pin.ID, p))
activeExitNodes.map(row => {
const pin = pinLookupMap.get(row.exit_node!);
if (!!pin) {
pin.pin.Route?.forEach(hop => {
pinIDs.add(hop)
})
}
})
return Array.from(pinIDs);
})
)
}
private formatActiveSinceDate(date: string): string {
const d = new Date(date);
const diff = Math.floor((new Date().getTime() - d.getTime()) / 1000);
const hours = Math.floor(diff / 3600);
const minutes = Math.floor((diff - (hours * 3600)) / 60);
const secs = diff - (hours * 3600) - (minutes * 60);
const pad = (d: number) => d < 10 ? `0${d}` : '' + d;
return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
}
}

View File

@@ -0,0 +1 @@
export * from './node-icon';

View File

@@ -0,0 +1,12 @@
<svg *ngIf="bySafing" class="inline-block" xmlns="http://www.w3.org/2000/svg" width="16" preserveAspectRation="none"
height="16">
<g transform="translate(9, 7)">
<polygon class="{{ nodeClass }}" points="0,-4 -4,4 4,4"></polygon>
</g>
</svg>
<svg *ngIf="!bySafing" class="inline-block" xmlns="http://www.w3.org/2000/svg" width="16" preserveAspectRation="none"
height="16">
<g transform="translate(9, 7)">
<circle class="{{ nodeClass }}" r="4"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 484 B

View File

@@ -0,0 +1,38 @@
svg {
circle,
polygon {
fill: #626262;
stroke: #626262;
stroke-width: 1;
stroke-linejoin: round;
transition: all 200ms linear 0s;
}
polygon.active,
polygon.exit {
fill: #0376bb;
stroke: #0376bb;
transform: scale(1.15)
}
circle.active,
circle.exit {
fill: #239215;
stroke: #239215;
transform: scale(1.15)
}
circle.exit,
polygon.exit {
stroke-width: 2;
}
circle.exit {
stroke: #30ae20;
}
polygon.exit {
stroke: #039af4;
}
}

View File

@@ -0,0 +1,44 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { ChangeDetectionStrategy, Component, Input } from "@angular/core";
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'spn-node-icon',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './node-icon.html',
styleUrls: ['./node-icon.scss'],
})
export class SpnNodeIconComponent {
@Input()
set bySafing(v: any) {
this._bySafing = coerceBooleanProperty(v);
}
get bySafing() { return this._bySafing }
private _bySafing = false;
@Input()
set isActive(v: any) {
this._isActive = coerceBooleanProperty(v);
}
get isActive() { return this._isActive }
private _isActive = false;
@Input()
set isExit(v: any) {
this._isExit = coerceBooleanProperty(v);
}
get isExit() { return this._isExit; }
private _isExit = false;
get nodeClass() {
if (this._isExit) {
return 'exit';
}
if (this.isActive) {
return 'active'
}
return '';
}
}

View File

@@ -0,0 +1 @@
export * from './pin-details';

View File

@@ -0,0 +1,127 @@
<h1 class="flex flex-row items-center gap-2 text-base">
<span [appCountryFlags]="pin?.entity?.Country || ''"></span>
{{ pin?.pin?.Name || 'N/A' }}
<span class="flex-grow"></span>
<svg *ngIf="!!dialogRef" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2" class="w-4 h-4 ml-2 opacity-75 cursor-pointer hover:opacity-100" (click)="dialogRef.close()">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</h1>
<span class="text-sm inline-block mt-.5 mb-2 font-thin" *ngIf="pin as pin">
This SPN Node is run by
<svg sfng-tooltip="Verified operator: {{pin.pin.VerifiedOwner}}" *ngIf="!!pin.pin.VerifiedOwner"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="inline-block w-4 h-4 mx-1 -mt-1 text-green-300">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z" />
</svg>
<span class="font-normal">{{ pin.pin.VerifiedOwner || 'Community' }}</span>
</span>
<div *ngIf="pin?.isOffline" class="text-sm mt-.5 mb-2 font-thin text-red-300">
Node is Offline
</div>
<div *ngIf="pin?.hasIssues && !pin?.isOffline" class="text-sm mt-.5 mb-2 font-thin text-yellow-300">
Node has Issues
</div>
<sfng-tab-group *ngIf="pin as pin" linkRouter="false">
<sfng-tab id="details" title="Details">
<table *sfngTabContent class="custom">
<tr>
<td class="p-2 font-thin">ID</td>
<td>{{ pin.pin.ID }}</td>
</tr>
<tr>
<td class="p-2 font-thin">Verified Owner</td>
<td>
<pre>{{ pin.pin.VerifiedOwner }}</pre>
</td>
</tr>
<tr>
<td class="p-2 font-thin">First Seen</td>
<td>{{ pin.pin.FirstSeen | date:'medium' }}</td>
</tr>
<tr>
<td class="p-2 font-thin">IPv4</td>
<td *ngIf="pin.pin.EntityV4 as entity">
<div class="flex flex-col gap-1">
<span class="text-primary">
<span [appCountryFlags]="entity.Country"></span>
{{ entity.ASOrg }}
<span class="font-thin text-tertiary">({{ entity.ASN }})</span>
</span>
<span class="text-primary">
{{ entity.IP || 'N/A' }}
</span>
</div>
</td>
</tr>
<tr>
<td class="p-2 font-thin">IPv6</td>
<td *ngIf="pin.pin.EntityV6 as entity">
<div class="flex flex-col gap-1">
<span class="text-primary">
<span [appCountryFlags]="entity.Country"></span>
{{ entity.ASOrg }}
<span class="font-thin text-tertiary">({{ entity.ASN }})</span>
</span>
<span class="text-primary">
{{ entity.IP || 'N/A' }}
</span>
</div>
</td>
</tr>
<tr>
<td class="p-2 font-thin">States</td>
<td>
<pre>{{ pin.pin.States.join(", ") }}</pre>
</td>
</tr>
<tr>
<td class="p-2 font-thin">SessionActive</td>
<td>
<pre>{{ pin.pin.SessionActive }}</pre>
</td>
</tr>
<tr>
<td class="p-2 font-thin">HopDistance</td>
<td>
<pre>{{ pin.pin.HopDistance }}</pre>
</td>
</tr>
<tr>
<td class="p-2 font-thin">Exit Connections</td>
<td>
<div class="flex flex-row items-center gap-2 cursor-pointer" [routerLink]="['/monitor']"
[queryParams]="{q: 'exit_node:' + pin.pin.ID}" (click)="dialogRef?.close()">
<pre>{{ exitConnectionCount }}</pre>
<svg viewBox="0 0 24 24" class="w-4 h-4" sfng-tooltip="Show exit connections in monitor."
*ngIf="exitConnectionCount > 0">
<g fill="none" stroke="currentColor">
<path shape-rendering="geometricPrecision" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M8.464 8.464c-1.953 1.953-1.953 5.118 0 7.071 1.953 1.953 5.118 1.953 7.071 0 1.953-1.953 1.953-5.119 0-7.071C14.559 7.488 13.28 7 12 7" />
<path shape-rendering="geometricPrecision" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M5.636 5.636c-3.515 3.515-3.515 9.213 0 12.728 3.515 3.515 9.213 3.515 12.728 0 3.515-3.515 3.515-9.213 0-12.728-2.627-2.627-6.474-3.289-9.717-1.989M5.64 5.64L12 12" />
</g>
</svg>
</div>
</td>
</tr>
</table>
</sfng-tab>
<sfng-tab id="routeHome" title="Route" *ngIf="!!pin.pin.Route">
<div *sfngTabContent>
<sfng-spn-pin-route [route]="pin.pin.Route"></sfng-spn-pin-route>
</div>
</sfng-tab>
<sfng-tab id="connectedHubs" title="Connected Nodes">
<spn-pin-list *sfngTabContent [pins]="connectedPins" allowHover="false" allowClick="false"></spn-pin-list>
</sfng-tab>
</sfng-tab-group>

View File

@@ -0,0 +1,100 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, OnChanges, OnDestroy, OnInit, Optional, SimpleChanges } from '@angular/core';
import { Netquery } from '@safing/portmaster-api';
import { SFNG_DIALOG_REF, SfngDialogRef } from '@safing/ui';
import { Subscription, forkJoin, map, of, switchMap } from 'rxjs';
import { LaneModel } from '../pin-list/pin-list';
import { MapPin, MapService } from './../map.service';
@Component({
templateUrl: './pin-details.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PinDetailsComponent implements OnInit, OnChanges, OnDestroy {
private subscription = Subscription.EMPTY;
@Input()
mapPinID!: string;
pin: MapPin | null = null;
/** Holds all pins this pin has a active connection to */
connectedPins: LaneModel[] = [];
/** The number of connections that exit at this pin */
exitConnectionCount: number = 0;
constructor(
private mapService: MapService,
private netquery: Netquery,
private cdr: ChangeDetectorRef,
@Optional() @Inject(SFNG_DIALOG_REF) public dialogRef?: SfngDialogRef<PinDetailsComponent, never, string>,
) { }
ngOnInit(): void {
// if we got opened via a dialog we get the map pin ID from the dialog data.
if (!!this.dialogRef) {
this.mapPinID = this.dialogRef.data;
}
this.subscription.unsubscribe();
this.subscription = this.mapService
.pins$
.pipe(
map(pins => {
return [pins.find(p => p.pin.ID === this.mapPinID), pins] as [MapPin, MapPin[]];
}),
switchMap(([pin, allPins]) => forkJoin({
pin: of(pin),
allPins: of(allPins),
exitConnections: this.netquery.query({
select: [
{ $count: { field: '*', as: 'totalCount', } },
],
query: {
exit_node: pin.pin.ID,
},
groupBy: ['exit_node']
}, 'pin-details-get-connections-per-exit-node')
}))
)
.subscribe((result) => {
this.pin = result.pin || null;
const lm = new Map<string, MapPin>();
result.allPins.forEach(pin => lm.set(pin.pin.ID, pin))
const connectedTo = this.pin?.pin.ConnectedTo || {};
this.connectedPins = Object.keys(connectedTo)
.map(pinID => {
const pin = lm.get(pinID)!;
return {
...connectedTo[pinID],
mapPin: pin,
}
});
if (result.exitConnections.length) {
// we expect only one row to be returned for the above query.
this.exitConnectionCount = result.exitConnections[0].totalCount;
} else {
this.exitConnectionCount = 0;
}
this.cdr.markForCheck();
})
}
ngOnChanges(changes: SimpleChanges) {
// if we got rendered directly (without a dialog) we need to
// handle updates to the mapPinID input field by re-loading the
// pin details. We do that by simply re-running ngOnInit
if (!!changes['mapPinID']) {
this.ngOnInit()
}
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
}

View File

@@ -0,0 +1,84 @@
<table>
<thead>
<th>Name</th>
<th><span class="pl-5">Operator</span></th>
<th>Used As</th>
<th *ngIf="!!lanes">Latency</th>
<th *ngIf="!!lanes">Capacity</th>
<th>IPv4</th>
<th>IPv6</th>
<th *ngIf="allowClick"></th>
</thead>
<tbody>
<tr class="border-l-2 border-transparent" [ngClass]="{'hover:border-l-yellow-300': allowHover}"
*ngFor="let pin of pins; trackBy: trackPin" (mouseenter)="pinHover.next(pin.pin.ID)"
(mouseleave)="pinHover.next(null)">
<td>
<spn-node-icon [bySafing]="pin.pin.VerifiedOwner === 'Safing'" [isExit]="pin.isExit" [isActive]="pin.isActive">
</spn-node-icon>
{{ pin.pin.Name }}
</td>
<td>
<div class="flex flex-row items-center gap-1 text-secondary">
<svg sfng-tooltip="Verified operator: {{pin.pin.VerifiedOwner}}" *ngIf="!!pin.pin.VerifiedOwner"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="inline-block w-4 h-4 -mt-1 text-green-300">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z" />
</svg>
<span [ngClass]="{'pl-5': !pin.pin.VerifiedOwner}">
{{ pin.pin.VerifiedOwner || 'Community' }}
</span>
</div>
</td>
<td>
<div class="flex flex-row items-center gap-2">
<!-- Home Node Icon -->
<svg sfng-tooltip="Home Node" *ngIf="pin.isHome" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 text-blue">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
<!-- Exit Node Icon -->
<svg sfng-tooltip="Exit Node" *ngIf="pin.isExit" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 text-blue">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z" />
</svg>
<!-- Transit Node Icon -->
<svg sfng-tooltip="Transit Node" *ngIf="pin.isTransit && !pin.isHome" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 text-blue">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
</div>
</td>
<ng-container *ngIf="!!lanes && lanes.get(pin.pin.ID) as val">
<td>
{{ val.Latency / 1000 / 1000 | number:'1.0-2' }} ms
</td>
<td>
{{ val.Capacity / 1000 / 1000 | number:'1.0-2' }} Mbit/s
</td>
</ng-container>
<td>{{ pin.pin.EntityV4?.IP || 'N/A' }}</td>
<td>{{ pin.pin.EntityV6?.IP || 'N/A' }}</td>
<td *ngIf="allowClick">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-4 h-4 cursor-pointer text-secondary hover:text-primary" (click)="pinClick.next(pin.pin.ID)">
<path stroke-linecap="round" stroke-linejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,87 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output, TrackByFunction } from '@angular/core';
import { Lane } from '@safing/portmaster-api';
import { take } from 'rxjs/operators';
import { MapPin } from '../map.service';
import { MapService } from './../map.service';
export interface LaneModel extends Lane {
mapPin: MapPin;
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'spn-pin-list',
templateUrl: './pin-list.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SpnPinListComponent {
@Input()
set allowHover(v: any) {
this._allowHover = coerceBooleanProperty(v);
}
get allowHover() { return this._allowHover }
private _allowHover = true;
@Input()
set allowClick(v: any) {
this._allowClick = coerceBooleanProperty(v);
}
get allowClick() { return this._allowClick }
private _allowClick = true;
@Input()
set pins(pins: (string | MapPin | LaneModel)[]) {
this.mapService
.pinsMap$
.pipe(take(1))
.subscribe(allPins => {
this.lanes = null;
this._pins = (pins || []).map(idOrPin => {
if (typeof idOrPin === 'string') {
return allPins.get(idOrPin)!;
}
if ('mapPin' in idOrPin) { // LaneModel
if (this.lanes === null) {
this.lanes = new Map();
}
this.lanes.set(idOrPin.HubID, {
Capacity: idOrPin.Capacity,
Latency: idOrPin.Latency,
})
return idOrPin.mapPin;
}
return idOrPin; // MapPin
})
this.cdr.markForCheck();
})
}
get pins(): MapPin[] {
return this._pins;
}
private _pins: MapPin[] = [];
/** If we got LaneModel in @Input() pins than this will contain a map with the capacity/latency */
lanes: Map<string, Pick<LaneModel, 'Capacity' | 'Latency'>> | null = null;
/** Emits the ID of the pin that got hovered, null if the mouse left a pin */
@Output()
pinHover = new EventEmitter<string | null>();
@Output()
pinClick = new EventEmitter<string>();
/** @private - A {@link TrackByFunction} for all pins available in this country */
trackPin: TrackByFunction<MapPin> = (_: number, pin: MapPin) => pin.pin.ID;
constructor(
private mapService: MapService,
private cdr: ChangeDetectorRef
) { }
}

Some files were not shown because too many files have changed in this diff Show More