better interfaces abstractions

This commit is contained in:
Matt Hill
2024-03-26 16:37:06 -06:00
parent d202cb731d
commit 641e829e3f
16 changed files with 248 additions and 275 deletions

View File

@@ -0,0 +1,55 @@
import {
ChangeDetectionStrategy,
Component,
Input,
inject,
} from '@angular/core'
import { AddressItemComponent } from './address-item.component'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { AddressDetails, AddressesService } from './interface.utils'
@Component({
standalone: true,
selector: 'app-address-group',
template: `
<div>
@if (addresses.length) {
<button
class="icon-add-btn"
tuiIconButton
appearance="secondary"
iconLeft="tuiIconPlus"
(click)="service.add()"
>
Add
</button>
}
<ng-content></ng-content>
</div>
@for (address of addresses; track $index) {
<app-address-item [label]="address.label" [address]="address.url" />
} @empty {
<button
tuiButton
iconLeft="tuiIconPlus"
[style.align-self]="'flex-start'"
(click)="service.add()"
>
Add Address
</button>
}
`,
imports: [AddressItemComponent, TuiButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: `
.icon-add-btn {
float: right;
margin-left: 2rem;
}
`,
})
export class AddressGroupComponent {
readonly service = inject(AddressesService)
@Input({ required: true }) addresses!: AddressDetails[]
}

View File

@@ -17,20 +17,24 @@ import {
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { QRComponent } from 'src/app/common/qr.component'
import { mask } from 'src/app/util/mask'
import { InterfaceComponent } from './interface.component'
import { AddressesService } from './interface.utils'
@Component({
standalone: true,
selector: 'app-interface-address',
selector: 'app-address-item',
template: `
<div tuiCell>
<tui-badge appearance="success">
{{ label }}
</tui-badge>
<h3 tuiTitle>
<span tuiSubtitle>{{ isMasked ? mask : address }}</span>
<span tuiSubtitle>
{{ interface.serviceInterface.masked ? mask : address }}
</span>
</h3>
<button
*ngIf="isUi"
*ngIf="interface.serviceInterface.type === 'ui'"
tuiIconButton
iconLeft="tuiIconExternalLink"
appearance="icon"
@@ -58,7 +62,7 @@ import { mask } from 'src/app/util/mask'
tuiIconButton
iconLeft="tuiIconTrash"
appearance="icon"
(click)="destroy()"
(click)="service.remove()"
>
Destroy
</button>
@@ -73,15 +77,16 @@ import { mask } from 'src/app/util/mask'
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressComponent {
export class AddressItemComponent {
private readonly window = inject(WINDOW)
private readonly dialogs = inject(TuiDialogService)
readonly service = inject(AddressesService)
readonly copyService = inject(CopyService)
readonly interface = inject(InterfaceComponent)
@Input() label?: string
@Input({ required: true }) address!: string
@Input({ required: true }) isMasked!: boolean
@Input({ required: true }) isUi!: boolean
get mask(): string {
return mask(this.address, 64)
@@ -99,6 +104,4 @@ export class InterfaceAddressComponent {
})
.subscribe()
}
destroy() {}
}

View File

@@ -0,0 +1,79 @@
import { Directive, Input } from '@angular/core'
import { AddressesService } from '../interface.utils'
import { inject } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions } from '@taiga-ui/core'
import {
FormComponent,
FormContext,
} from 'src/app/apps/portal/components/form.component'
import { getClearnetSpec } from 'src/app/apps/portal/components/interfaces/interface.utils'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { NetworkInfo } from 'src/app/services/patch-db/data-model'
import { InterfaceComponent } from '../interface.component'
type ClearnetForm = {
domain: string
subdomain: string | null
}
@Directive({
standalone: true,
selector: '[clearnetAddresses]',
providers: [
{ provide: AddressesService, useExisting: ClearnetAddressesDirective },
],
})
export class ClearnetAddressesDirective implements AddressesService {
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
@Input({ required: true }) network!: NetworkInfo
async add() {
const options: Partial<TuiDialogOptions<FormContext<ClearnetForm>>> = {
label: 'Select Domain/Subdomain',
data: {
spec: await getClearnetSpec(this.network),
buttons: [
{
text: 'Manage domains',
link: 'portal/system/settings/domains',
},
{
text: 'Save',
handler: async value => this.save(value),
},
],
},
}
this.formDialog.open(FormComponent, options)
}
async remove() {}
private async save(domainInfo: ClearnetForm): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
if (this.interface.packageContext) {
await this.api.setInterfaceClearnetAddress({
...this.interface.packageContext,
domainInfo,
})
} else {
await this.api.setServerClearnetAddress({ domainInfo })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,14 @@
import { Directive } from '@angular/core'
import { AddressesService } from '../interface.utils'
@Directive({
standalone: true,
selector: '[localAddresses]',
providers: [
{ provide: AddressesService, useExisting: LocalAddressesDirective },
],
})
export class LocalAddressesDirective implements AddressesService {
async add() {}
async remove() {}
}

View File

@@ -0,0 +1,14 @@
import { Directive } from '@angular/core'
import { AddressesService } from '../interface.utils'
@Directive({
standalone: true,
selector: '[torAddresses]',
providers: [
{ provide: AddressesService, useExisting: TorAddressesDirective },
],
})
export class TorAddressesDirective implements AddressesService {
async add() {}
async remove() {}
}

View File

@@ -1,144 +0,0 @@
import { NgForOf, NgIf } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/apps/portal/components/form.component'
import {
getClearnetSpec,
REMOVE,
} from 'src/app/apps/portal/components/interfaces/interface.utils'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { NetworkInfo } from 'src/app/services/patch-db/data-model'
import { InterfaceAddressComponent } from './interface-addresses.component'
import { InterfaceComponent } from './interface.component'
type ClearnetForm = {
domain: string
subdomain: string | null
}
@Component({
standalone: true,
selector: 'app-interface-clearnet',
template: `
<em>
Add clearnet to expose this interface to the public Internet.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#clearnet"
target="_blank"
rel="noreferrer"
>
<strong>View instructions</strong>
</a>
</em>
@for (
address of interface.serviceInterface.addresses.clearnet;
track $index
) {
<app-interface-address
[label]="address.label"
[address]="address.url"
[isMasked]="interface.serviceInterface.masked"
[isUi]="interface.serviceInterface.type === 'ui'"
/>
} @empty {
<button
tuiButton
iconLeft="tuiIconPlus"
[style.align-self]="'flex-start'"
(click)="add()"
>
Add Address
</button>
}
`,
imports: [NgForOf, InterfaceAddressComponent, NgIf, TuiButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceClearnetComponent {
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
readonly interface = inject(InterfaceComponent)
@Input({ required: true }) network!: NetworkInfo
async add() {
const options: Partial<TuiDialogOptions<FormContext<ClearnetForm>>> = {
label: 'Select Domain/Subdomain',
data: {
spec: await getClearnetSpec(this.network),
buttons: [
{
text: 'Manage domains',
link: 'portal/system/settings/domains',
},
{
text: 'Save',
handler: async value => this.save(value),
},
],
},
}
this.formDialog.open(FormComponent, options)
}
remove() {
this.dialogs
.open(TUI_PROMPT, REMOVE)
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing...').subscribe()
try {
if (this.interface.packageContext) {
await this.api.setInterfaceClearnetAddress({
...this.interface.packageContext,
domainInfo: null,
})
} else {
await this.api.setServerClearnetAddress({ domainInfo: null })
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
private async save(domainInfo: ClearnetForm): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
if (this.interface.packageContext) {
await this.api.setInterfaceClearnetAddress({
...this.interface.packageContext,
domainInfo,
})
} else {
await this.api.setServerClearnetAddress({ domainInfo })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,50 +0,0 @@
import { NgForOf, NgIf } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { InterfaceComponent } from './interface.component'
import { InterfaceAddressComponent } from './interface-addresses.component'
@Component({
standalone: true,
selector: 'app-interface-local',
template: `
<em>
Local addresses can only be accessed while connected to the same Local
Area Network (LAN) as your server, either directly or using a VPN.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#local"
target="_blank"
rel="noreferrer"
>
<strong>View instructions</strong>
</a>
</em>
@for (address of interface.serviceInterface.addresses.local; track $index) {
<app-interface-address
[label]="address.label"
[address]="address.url"
[isMasked]="interface.serviceInterface.masked"
[isUi]="interface.serviceInterface.type === 'ui'"
/>
} @empty {
<button
tuiButton
iconLeft="tuiIconPlus"
[style.align-self]="'flex-start'"
(click)="add()"
>
Add Address
</button>
}
`,
imports: [NgForOf, NgIf, InterfaceAddressComponent, TuiButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceLocalComponent {
readonly interface = inject(InterfaceComponent)
async add() {}
async remove() {}
}

View File

@@ -1,50 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { InterfaceAddressComponent } from './interface-addresses.component'
import { InterfaceComponent } from './interface.component'
import { NgForOf, NgIf } from '@angular/common'
import { TuiButtonModule } from '@taiga-ui/experimental'
@Component({
standalone: true,
selector: 'app-interface-tor',
template: `
<em>
Use a Tor-enabled browser to access this address. Tor connections can be
slow and unreliable.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#tor"
target="_blank"
rel="noreferrer"
>
<strong>View instructions</strong>
</a>
</em>
@for (address of interface.serviceInterface.addresses.tor; track $index) {
<app-interface-address
[label]="address.label"
[address]="address.url"
[isMasked]="interface.serviceInterface.masked"
[isUi]="interface.serviceInterface.type === 'ui'"
/>
} @empty {
<button
tuiButton
iconLeft="tuiIconPlus"
[style.align-self]="'flex-start'"
(click)="add()"
>
Add Address
</button>
}
`,
imports: [NgForOf, NgIf, InterfaceAddressComponent, TuiButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceTorComponent {
readonly interface = inject(InterfaceComponent)
async add() {}
async remove() {}
}

View File

@@ -8,38 +8,89 @@ import {
import { T } from '@start9labs/start-sdk'
import { TuiCardModule, TuiSurfaceModule } from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client'
import { InterfaceClearnetComponent } from 'src/app/apps/portal/components/interfaces/interface-clearnet.component'
import { InterfaceLocalComponent } from 'src/app/apps/portal/components/interfaces/interface-local.component'
import { InterfaceTorComponent } from 'src/app/apps/portal/components/interfaces/interface-tor.component'
import { AddressGroupComponent } from 'src/app/apps/portal/components/interfaces/address-group.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { AddressDetails } from './interface.utils'
import { ClearnetAddressesDirective } from './directives/clearnet.directive'
import { LocalAddressesDirective } from './directives/local.directive'
import { TorAddressesDirective } from './directives/tor.directive'
@Component({
standalone: true,
selector: 'app-interface',
template: `
<h3 class="g-title">Clearnet</h3>
<app-interface-clearnet
<app-address-group
*ngIf="network$ | async as network"
clearnetAddresses
tuiCardLarge="compact"
tuiSurface="elevated"
[network]="network"
/>
[addresses]="serviceInterface.addresses.clearnet"
>
<em>
Add a clearnet address to expose this interface on the Internet.
Clearnet addresses are fully public and not anonymous.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#clearnet"
target="_blank"
rel="noreferrer"
>
<strong>Learn More</strong>
</a>
</em>
</app-address-group>
<h3 class="g-title">Tor</h3>
<app-interface-tor tuiCardLarge="compact" tuiSurface="elevated" />
<app-address-group
torAddresses
tuiCardLarge="compact"
tuiSurface="elevated"
[addresses]="serviceInterface.addresses.tor"
>
<em>
Add an onion address to anonymously expose this interface on the
darknet. Onion addresses can only be reached over the Tor network.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#tor"
target="_blank"
rel="noreferrer"
>
<strong>Learn More</strong>
</a>
</em>
</app-address-group>
<h3 class="g-title">Local</h3>
<app-interface-local tuiCardLarge="compact" tuiSurface="elevated" />
<app-address-group
localAddresses
tuiCardLarge="compact"
tuiSurface="elevated"
[addresses]="serviceInterface.addresses.local"
>
<em>
Add a local address to expose this interface on your Local Area Network
(LAN). Local addresses can only be accessed by devices connected to the
same LAN as your server, either directly or using a VPN.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#local"
target="_blank"
rel="noreferrer"
>
<strong>Learn More</strong>
</a>
</em>
</app-address-group>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
InterfaceTorComponent,
InterfaceLocalComponent,
InterfaceClearnetComponent,
AddressGroupComponent,
TuiCardModule,
TuiSurfaceModule,
ClearnetAddressesDirective,
TorAddressesDirective,
LocalAddressesDirective,
],
})
export class InterfaceComponent {

View File

@@ -4,6 +4,11 @@ import { TuiPromptData } from '@taiga-ui/kit'
import { NetworkInfo } from 'src/app/services/patch-db/data-model'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
export abstract class AddressesService {
abstract add(): Promise<void>
abstract remove(): Promise<void>
}
export const REMOVE: Partial<TuiDialogOptions<TuiPromptData>> = {
label: 'Confirm',
size: 's',

View File

@@ -82,7 +82,7 @@ export class ControlsComponent {
return this.errors.getPkgDepErrors$(id).pipe(
map(errors =>
Object.keys(this.pkg.currentDependencies)
.map(id => !!(errors[id] as any)?.[id]) // @TODO fix
.map(id => !!(errors[id] as any)?.[id]) // @TODO-Alex fix
.some(Boolean),
),
)

View File

@@ -122,7 +122,7 @@ export class SettingsService {
await this.proxyService.presentModalSetOutboundProxy(proxy)
}
// @TODO previous this was done in experimental settings using a template ref.
// @TODO-Alex previous this was done in experimental settings using a template ref.
private promptResetTor() {
this.dialogs
.open(TUI_PROMPT, {

View File

@@ -27,7 +27,7 @@ export async function parseS9pk(file: File): Promise<MarketplacePkg> {
const manifest = await getAsset(positions, file, 'manifest')
const [icon] = await Promise.all([
await getIcon(positions, file, manifest),
await getIcon(positions, file),
// getAsset(positions, file, 'license'),
// getAsset(positions, file, 'instructions'),
])
@@ -147,11 +147,7 @@ async function getAsset(
return cbor.decode(data, true)
}
async function getIcon(
positions: Positions,
file: Blob,
manifest: Manifest,
): Promise<string> {
async function getIcon(positions: Positions, file: Blob): Promise<string> {
const contentType = '' // @TODO
const data = file.slice(
Number(positions['icon'][0]),

View File

@@ -73,7 +73,7 @@ export class BadgeService {
new Set<string>(),
).size,
),
// @TODO shareReplay is preventing the badge from decrementing
// @TODO-Alex shareReplay is preventing the badge from decrementing
shareReplay(1),
)

View File

@@ -1283,7 +1283,7 @@ export class MockApiService extends ApiService {
const patch = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.packageId}/installed/interfaceInfo/${params.interfaceId}/addressInfo/domainInfo`,
path: `/packageData/${params.packageId}/serviceInterfaces/${params.interfaceId}/addressInfo/domainInfo`,
value: params.domainInfo,
},
]
@@ -1299,7 +1299,7 @@ export class MockApiService extends ApiService {
const patch = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.packageId}/installed/outboundProxy`,
path: `/packageData/${params.packageId}/outboundProxy`,
value: params.proxy,
},
]

View File

@@ -10,7 +10,7 @@ import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
providedIn: 'root',
})
export class PatchMonitorService extends Observable<any> {
// @TODO not happy with Observable<void>
// @TODO-Alex not happy with Observable<void>
private readonly stream$ = this.authService.isVerified$.pipe(
tap(verified =>
verified ? this.patch.start(this.bootstrapper) : this.patch.stop(),