diff --git a/modules/portmaster/src/app/app.module.ts b/modules/portmaster/src/app/app.module.ts index 633ae24e..9c89d645 100644 --- a/modules/portmaster/src/app/app.module.ts +++ b/modules/portmaster/src/app/app.module.ts @@ -69,6 +69,7 @@ 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 { INTEGRATION_SERVICE, integrationServiceFactory } from './integration'; +import { SupportProgressDialogComponent } from './pages/support/progress-dialog'; function loadAndSetLocaleInitializer(configService: ConfigService) { return async function () { @@ -158,7 +159,8 @@ const localeConfig = { DashboardPageComponent, DashboardWidgetComponent, FeatureCardComponent, - MergeProfileDialogComponent + MergeProfileDialogComponent, + SupportProgressDialogComponent ], imports: [ BrowserModule, diff --git a/modules/portmaster/src/app/pages/support/form/support-form.ts b/modules/portmaster/src/app/pages/support/form/support-form.ts index 0ad05901..1b2e8ed1 100644 --- a/modules/portmaster/src/app/pages/support/form/support-form.ts +++ b/modules/portmaster/src/app/pages/support/form/support-form.ts @@ -13,6 +13,7 @@ import { fadeInAnimation, fadeInListAnimation, moveInOutAnimation } from 'src/ap import { FuzzySearchService } from 'src/app/shared/fuzzySearch'; import { SupportPage, supportTypes } from '../pages'; import { INTEGRATION_SERVICE } from 'src/app/integration'; +import { SupportProgressDialogComponent, TicketData, TicketInfo } from '../progress-dialog'; @Component({ templateUrl: './support-form.html', @@ -156,6 +157,46 @@ export class SupportFormComponent implements OnInit { return valid; } + createIssue(type: 'github' | 'private', genUrl?: boolean, email?: string) { + const ticketData: TicketData = { + repo: this.selectedRepo || '', + title: this.title, + debugInfo: this.debugData, + sections: this.page?.sections.map(section => ({ + title: section.title, + body: this.form[section.title], + })) || [], + } + + let issue: TicketInfo; + + switch (type) { + case 'github': + issue = { + type: 'github', + generateUrl: genUrl || false, + preset: this.page!.ghIssuePreset || '', + ...ticketData + }; + + break; + + case 'private': + issue = { + type: 'private', + email: email, + ...ticketData + } + + break; + } + + SupportProgressDialogComponent.open(this.dialog, issue) + .subscribe(() => { + this.sessionService.delete(this.page?.id || ''); + }); + } + createOnGithub(genUrl?: boolean) { if (!this.validate()) { return; @@ -177,61 +218,13 @@ export class SupportFormComponent implements OnInit { ] }) .onAction('openGithub', () => { - this.createOnGithub(true); + this.createIssue('github', true) }) .onAction('createWithout', () => { - this.createOnGithub(false); + this.createIssue('github', false) }) return; } - - let debugInfo: Observable = this.supporthub.uploadText('debug-info', this.debugData); - if (!this.page?.includeDebugData) { - debugInfo = of(''); - } - - debugInfo - .pipe( - mergeMap(url => this.supporthub.createIssue( - this.selectedRepo, - this.page?.ghIssuePreset || '', - this.title, - this.page!.sections.map(section => ({ - title: section.title, - body: this.form[section.title], - })), - url, - { generateUrl: genUrl || false } - )) - ) - .subscribe({ - next: url => { - this.sessionService.delete(this.page?.id || ''); - const openUrl = () => { - this.integration.openExternal(url); - } - - if (genUrl === true) { - openUrl(); - return; - } - - const opts: ConfirmDialogConfig = { - canCancel: false, - buttons: [{ id: '', text: 'Close', class: 'outline' }, { id: 'open', text: 'Open Issue' }], - caption: 'Info', - header: 'Issue Created!', - message: 'We successfully created the issue on Github for you. Use the following link to check for updates: ' + url, - } - this.dialog.confirm(opts) - .onAction('open', () => { - openUrl(); - }) - }, - error: err => { - this.uai.error('Failed to create issue', this.uai.getErrorMessgae(err)) - } - }) } openIssue(issue: Issue) { @@ -258,37 +251,7 @@ export class SupportFormComponent implements OnInit { } this.dialog.confirm(opts) .onAction('create', () => { - let debugInfo: Observable = this.supporthub.uploadText('debug-info', this.debugData); - if (!this.page?.includeDebugData) { - debugInfo = of(''); - } - - debugInfo - .pipe( - mergeMap(url => this.supporthub.createTicket( - this.selectedRepo, - this.title, - opts.inputModel || '', - this.page!.sections.map(section => ({ - title: section.title, - body: this.form[section.title], - })), - url, - )) - ) - .subscribe({ - next: () => { - let msg = ''; - if (!!opts.inputModel) { - msg = 'You will be contacted as soon as possible'; - } - this.uai.success('Ticket created successfully', msg) - this.sessionService.delete(this.page?.id || ''); - }, - error: err => { - this.uai.error('Failed to create ticket', this.uai.getErrorMessgae(err)) - } - }) + this.createIssue('private', undefined, opts.inputModel); }); } diff --git a/modules/portmaster/src/app/pages/support/progress-dialog/index.ts b/modules/portmaster/src/app/pages/support/progress-dialog/index.ts new file mode 100644 index 00000000..0dfbf366 --- /dev/null +++ b/modules/portmaster/src/app/pages/support/progress-dialog/index.ts @@ -0,0 +1 @@ +export * from './progress-dialog'; diff --git a/modules/portmaster/src/app/pages/support/progress-dialog/progress-dialog.html b/modules/portmaster/src/app/pages/support/progress-dialog/progress-dialog.html new file mode 100644 index 00000000..2504a56e --- /dev/null +++ b/modules/portmaster/src/app/pages/support/progress-dialog/progress-dialog.html @@ -0,0 +1,114 @@ + +
+ + Status + +
+ + + + +
+ + + Uploading debug data .... + + + + + Creating GitHub issue ... + + + + + Creating private support ticket ... + +
+
+
+ + + + + + + + + + + + Ticket prepared successfully + + + + Ticket created successfully! + + + + +
+ Use the following button to open the pre-filled GitHub issue form: + +
+ +
+ +
+
+ +
+ + We successfully create the issue on GitHub for you. +
+ Use the following link to check for updates: +
+ + {{ url }} +
+ + + We will contact you as soon as possbile. + +
+ + +
+ + + + + + + + Failed to create Support Ticket + + + + + + An error occured while creating your support ticket: + + + + {{ error || 'Unknown Error' }} + +
+ + +
+ + + + + + +
diff --git a/modules/portmaster/src/app/pages/support/progress-dialog/progress-dialog.ts b/modules/portmaster/src/app/pages/support/progress-dialog/progress-dialog.ts new file mode 100644 index 00000000..32deb205 --- /dev/null +++ b/modules/portmaster/src/app/pages/support/progress-dialog/progress-dialog.ts @@ -0,0 +1,173 @@ +import { ComponentPortal } from "@angular/cdk/portal"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, EventEmitter, OnInit, inject } from "@angular/core"; +import { SFNG_DIALOG_REF, SfngDialogRef, SfngDialogService } from "@safing/ui"; +import { Observable, map, mergeMap, of } from "rxjs"; +import { INTEGRATION_SERVICE } from "src/app/integration"; +import { SupportHubService, SupportSection } from "src/app/services"; +import { ActionIndicatorService } from "src/app/shared/action-indicator"; + +export interface TicketData { + debugInfo: string; + repo: string; + title: string; + sections: SupportSection[]; +} + +export interface GithubIssue extends TicketData { + type: 'github', + generateUrl?: boolean; + preset?: string; +} + +export interface PrivateTicket extends TicketData { + type: 'private', + email?: string, +} + +export type TicketInfo = GithubIssue | PrivateTicket; + + +@Component({ + templateUrl: './progress-dialog.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + ` + :host { + @apply block flex flex-col gap-8 relative; + } + `, + ] +}) +export class SupportProgressDialogComponent implements OnInit { + + /** Static method to open the support-progress dialog. */ + static open(dialog: SfngDialogService, data: TicketInfo): Observable { + const ref = dialog.create(SupportProgressDialogComponent, { + data, + dragable: true, + backdrop: false, + autoclose: false, + }); + + return (ref.contentRef() as ComponentRef) + .instance + .done; + } + + + private readonly cdr = inject(ChangeDetectorRef); + private readonly supporthub = inject(SupportHubService); + private readonly uai = inject(ActionIndicatorService); + + readonly integration = inject(INTEGRATION_SERVICE); + + readonly dialogRef: SfngDialogRef = inject(SFNG_DIALOG_REF); + + /** Holds the current state of the issue-creation */ + state: '' | 'debug-info' | 'create-issue' | 'create-ticket' | 'done' | 'error' = ''; + + /** The URL to the github issue once it was created. */ + url: string = ''; + + /** The error message if one occured */ + error: string = ''; + + /** Emits once the issue has been created successfully */ + done = new EventEmitter; + + ngOnInit(): void { + this.createSupportRequest(); + } + + setState(state: typeof this['state']) { + this.state = state; + this.cdr.detectChanges(); + } + + createSupportRequest(): void { + const data = this.dialogRef.data; + let stream = of('') + + // Upload debug info + if (data.debugInfo) { + stream = new Observable((observer) => { + this.state = 'debug-info'; + this.cdr.detectChanges(); + + this.supporthub.uploadText('debug-info', data.debugInfo) + .subscribe(observer); + }) + } + + // either create on github or create a private ticket through support-hub + if (data.type === 'github') { + stream = stream.pipe( + mergeMap((url) => { + this.state = 'create-issue'; + this.cdr.detectChanges(); + + return this.supporthub.createIssue( + data.repo, + data.preset || '', + data.title, + data.sections, + url, + { + generateUrl: data.generateUrl || false + }, + ); + }) + ) + } else { + stream = stream.pipe( + mergeMap((url) => { + this.state = 'create-ticket'; + this.cdr.markForCheck(); + + return this.supporthub.createTicket( + data.repo, + data.title, + data.email || '', + data.sections, + url + ) + }), + map(() => '') + ) + } + + stream.subscribe({ + next: (url) => { + this.state = 'done'; + this.url = url; + this.cdr.markForCheck(); + + this.done.next(); + }, + + error: (err) => { + console.error("error", err); + + this.state = 'error'; + if (err instanceof HttpErrorResponse && err.error instanceof ProgressEvent) { + this.error = err.statusText; + } else { + this.error = this.uai.getErrorMessage(err); + } + + this.cdr.markForCheck(); + } + }); + } + + copyUrl() { + if (!this.url) { + return + } + + this.integration.writeToClipboard(this.url) + .then(() => this.uai.success('URL Copied To Clipboard')) + .catch(err => this.uai.error('Failed to Copy To Clipboard', this.uai.getErrorMessage(err))) + } +} diff --git a/modules/portmaster/src/app/services/supporthub.service.ts b/modules/portmaster/src/app/services/supporthub.service.ts index 49349834..9b8cfd7c 100644 --- a/modules/portmaster/src/app/services/supporthub.service.ts +++ b/modules/portmaster/src/app/services/supporthub.service.ts @@ -4,7 +4,7 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; -interface SupportSection { +export interface SupportSection { title: string; body: string; } diff --git a/modules/portmaster/src/app/shared/netquery/connection-row/conn-row.html b/modules/portmaster/src/app/shared/netquery/connection-row/conn-row.html index 849f871c..3c721b0b 100644 --- a/modules/portmaster/src/app/shared/netquery/connection-row/conn-row.html +++ b/modules/portmaster/src/app/shared/netquery/connection-row/conn-row.html @@ -37,8 +37,17 @@ + +
+ + {{ conn.country | countryName }} +
+
+ diff --git a/modules/portmaster/src/app/shared/netquery/netquery.module.ts b/modules/portmaster/src/app/shared/netquery/netquery.module.ts index e9ddbcd0..5a433666 100644 --- a/modules/portmaster/src/app/shared/netquery/netquery.module.ts +++ b/modules/portmaster/src/app/shared/netquery/netquery.module.ts @@ -1,7 +1,7 @@ import { A11yModule } from "@angular/cdk/a11y"; import { OverlayModule } from "@angular/cdk/overlay"; import { CommonModule } from "@angular/common"; -import { NgModule } from "@angular/core"; +import { inject, NgModule } from "@angular/core"; import { FormsModule } from "@angular/forms"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { SfngAccordionModule, SfngDropDownModule, SfngPaginationModule, SfngSelectModule, SfngTipUpModule, SfngToggleSwitchModule, SfngTooltipModule } from "@safing/ui"; @@ -20,7 +20,7 @@ import { SfngNetqueryConnectionDetailsComponent } from "./connection-details"; import { SfngNetqueryConnectionRowComponent } from "./connection-row"; import { SfngNetqueryLineChartComponent } from "./line-chart/line-chart"; import { SfngNetqueryViewer } from "./netquery.component"; -import { CanShowConnection, CanUseRulesPipe, ConnectionLocationPipe, IsBlockedConnectionPipe } from "./pipes"; +import { CanShowConnection, CanUseRulesPipe, ConnectionLocationPipe, CountryNamePipe, CountryNameService, IsBlockedConnectionPipe } from "./pipes"; import { SfngNetqueryScopeLabelComponent } from "./scope-label"; import { SfngNetquerySearchOverlayComponent } from "./search-overlay"; import { SfngNetquerySearchbarComponent, SfngNetquerySuggestionDirective } from "./searchbar"; @@ -75,6 +75,14 @@ import { CircularBarChartComponent } from './circular-bar-chart/circular-bar-cha CanShowConnection, CombinedMenuPipe, CircularBarChartComponent, + CountryNamePipe, + ], + providers: [ + CountryNameService ] }) -export class NetqueryModule { } +export class NetqueryModule { + private _unusedBootstrap = [ + inject(CountryNameService), // make sure country names are loaded on bootstrap + ] +} diff --git a/modules/portmaster/src/app/shared/netquery/pipes/country-name.pipe.ts b/modules/portmaster/src/app/shared/netquery/pipes/country-name.pipe.ts new file mode 100644 index 00000000..93e6bc61 --- /dev/null +++ b/modules/portmaster/src/app/shared/netquery/pipes/country-name.pipe.ts @@ -0,0 +1,59 @@ +import { HttpClient } from '@angular/common/http'; +import { Pipe, PipeTransform, Injectable, inject } from '@angular/core'; +import { GeoCoordinates, SPNService } from '@safing/portmaster-api'; +import { environment } from 'src/environments/environment'; +import { ActionIndicatorService } from '../../action-indicator'; +import { objKeys } from '../../utils'; + +export interface CountryListResponse { + [countryKey: string]: { + Code: string; + Name: string; + Center: GeoCoordinates; + Continent: { + Code: string; + Region: string; + Name: string; + } + } +} + +@Injectable() +export class CountryNameService { + private readonly spn = inject(SPNService); + private readonly http = inject(HttpClient); + private readonly uai = inject(ActionIndicatorService); + + private map: Map = new Map(); + + constructor() { + this.http.get(`${environment.httpAPI}/v1/intel/geoip/countries`) + .subscribe({ + next: response => { + objKeys(response) + .forEach(key => { + this.map.set(key as string, response[key].Name); + }); + }, + error: err => { + this.uai.error('Failed to fetch country data', this.uai.getErrorMessage(err)); + } + }) + } + + resolveName(code: string): string { + return this.map.get(code) || ''; + } +} + +@Pipe({ + name: 'countryName', + pure: true, +}) +export class CountryNamePipe implements PipeTransform { + private countryService = inject(CountryNameService); + + transform(countryCode: string) { + return this.countryService.resolveName(countryCode); + } +} diff --git a/modules/portmaster/src/app/shared/netquery/pipes/index.ts b/modules/portmaster/src/app/shared/netquery/pipes/index.ts index 8a51bec1..9b429e59 100644 --- a/modules/portmaster/src/app/shared/netquery/pipes/index.ts +++ b/modules/portmaster/src/app/shared/netquery/pipes/index.ts @@ -2,3 +2,4 @@ export * from './location.pipe'; export * from './can-show.pipe'; export * from './can-use-rules.pipe'; export * from './is-blocked.pipe'; +export * from './country-name.pipe';