mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-01 21:13:09 +00:00
forms for adding domain, rework things based on new ideas
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { toAuthorityName } from 'src/app/utils/acme'
|
||||
|
||||
@Pipe({
|
||||
name: 'authorityName',
|
||||
})
|
||||
export class AuthorityNamePipe implements PipeTransform {
|
||||
transform(value: string | null = null): string {
|
||||
return toAuthorityName(value)
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,7 @@ import {
|
||||
} from '@taiga-ui/core'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
|
||||
|
||||
import { InterfaceComponent } from '../interface.component'
|
||||
import { InterfaceService } from '../interface.service'
|
||||
|
||||
@Component({
|
||||
selector: 'td[actions]',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { InterfaceAddressItemComponent } from './item.component'
|
||||
selector: 'section[addresses]',
|
||||
template: `
|
||||
<header>{{ 'Addresses' | i18n }}</header>
|
||||
<table [appTable]="[null, 'Type', 'Gateway', 'URL', null]">
|
||||
<table [appTable]="[null, 'Type', 'Access', 'Gateway', 'URL', null]">
|
||||
@for (address of addresses()?.common; track $index) {
|
||||
<tr [address]="address" [isRunning]="isRunning()"></tr>
|
||||
} @empty {
|
||||
@@ -27,7 +27,7 @@ import { InterfaceAddressItemComponent } from './item.component'
|
||||
} @else {
|
||||
@for (_ of [0, 1]; track $index) {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<td colspan="6">
|
||||
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { DisplayAddress } from '../interface.service'
|
||||
import { AddressActionsComponent } from './actions.component'
|
||||
import { TuiBadge } from '@taiga-ui/kit'
|
||||
|
||||
@Component({
|
||||
selector: 'tr[address]',
|
||||
@@ -17,13 +18,26 @@ import { AddressActionsComponent } from './actions.component'
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.eye"
|
||||
iconStart="@tui.info"
|
||||
(click)="viewDetails(address.bullets)"
|
||||
>
|
||||
{{ 'Address details' | i18n }}
|
||||
</button>
|
||||
</td>
|
||||
<td [style.width.rem]="6">{{ address.type }}</td>
|
||||
<td [style.width.rem]="5">
|
||||
@if (address.access === 'public') {
|
||||
<tui-badge size="s" appearance="primary-success">
|
||||
{{ 'public' | i18n }}
|
||||
</tui-badge>
|
||||
} @else if (address.access === 'private') {
|
||||
<tui-badge size="s" appearance="primary-destructive">
|
||||
{{ 'private' | i18n }}
|
||||
</tui-badge>
|
||||
} @else {
|
||||
-
|
||||
}
|
||||
</td>
|
||||
<td [style.width.rem]="10" [style.order]="-1">
|
||||
{{ address.gatewayName || '-' }}
|
||||
</td>
|
||||
@@ -54,7 +68,7 @@ import { AddressActionsComponent } from './actions.component'
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [i18nPipe, AddressActionsComponent, TuiButton],
|
||||
imports: [i18nPipe, AddressActionsComponent, TuiButton, TuiBadge],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class InterfaceAddressItemComponent {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
|
||||
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiSkeleton } from '@taiga-ui/kit'
|
||||
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
||||
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
||||
|
||||
import { DomainComponent } from './domain.component'
|
||||
import { ClearnetDomain } from './interface.service'
|
||||
|
||||
@Component({
|
||||
selector: 'section[clearnetDomains]',
|
||||
template: `
|
||||
<header>
|
||||
{{ 'Clearnet Domains' | i18n }}
|
||||
<a
|
||||
tuiIconButton
|
||||
docsLink
|
||||
path="/user-manual/connecting-remotely/clearnet.html"
|
||||
appearance="icon"
|
||||
iconStart="@tui.external-link"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
<button
|
||||
tuiButton
|
||||
iconStart="@tui.plus"
|
||||
[style.margin-inline-start]="'auto'"
|
||||
(click)="add()"
|
||||
>
|
||||
{{ 'Add' | i18n }}
|
||||
</button>
|
||||
</header>
|
||||
<table [appTable]="['Domain', 'Certificate Authority', 'Type', null]">
|
||||
@for (domain of clearnetDomains(); track $index) {
|
||||
<tr [domain]="domain"></tr>
|
||||
} @empty {
|
||||
@if (clearnetDomains()) {
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<app-placeholder icon="@tui.globe">
|
||||
{{ 'No clearnet domains' | i18n }}
|
||||
</app-placeholder>
|
||||
</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (_ of [0, 1]; track $index) {
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</table>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
grid-column: span 3;
|
||||
}
|
||||
`,
|
||||
host: { class: 'g-card' },
|
||||
imports: [
|
||||
TuiButton,
|
||||
TableComponent,
|
||||
PlaceholderComponent,
|
||||
i18nPipe,
|
||||
DocsLinkDirective,
|
||||
DomainComponent,
|
||||
TuiSkeleton,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class InterfaceClearnetDomainsComponent {
|
||||
readonly clearnetDomains = input.required<
|
||||
readonly ClearnetDomain[] | undefined
|
||||
>()
|
||||
|
||||
open = false
|
||||
|
||||
add() {}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
DocsLinkDirective,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiSkeleton } from '@taiga-ui/kit'
|
||||
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
||||
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
||||
import { InterfaceClearnetDomainsItemComponent } from './item.component'
|
||||
import { ClearnetDomain } from '../interface.service'
|
||||
import { ISB, utils } from '@start9labs/start-sdk'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { FormComponent } from '../../form.component'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { map } from 'rxjs'
|
||||
import { toAuthorityName } from 'src/app/utils/acme'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { InterfaceComponent } from '../interface.component'
|
||||
|
||||
// @TODO translations
|
||||
|
||||
@Component({
|
||||
selector: 'section[clearnetDomains]',
|
||||
template: `
|
||||
<header>
|
||||
{{ 'Clearnet Domains' | i18n }}
|
||||
<a
|
||||
tuiIconButton
|
||||
docsLink
|
||||
path="/user-manual/connecting-remotely/clearnet.html"
|
||||
appearance="icon"
|
||||
iconStart="@tui.external-link"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
<button
|
||||
tuiButton
|
||||
iconStart="@tui.plus"
|
||||
[style.margin-inline-start]="'auto'"
|
||||
(click)="add()"
|
||||
>
|
||||
{{ 'Add' | i18n }}
|
||||
</button>
|
||||
</header>
|
||||
<table [appTable]="['Domain', 'Certificate Authority', 'Type', null]">
|
||||
@for (domain of clearnetDomains(); track $index) {
|
||||
<tr [domain]="domain"></tr>
|
||||
} @empty {
|
||||
@if (clearnetDomains()) {
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<app-placeholder icon="@tui.globe">
|
||||
{{ 'No clearnet domains' | i18n }}
|
||||
</app-placeholder>
|
||||
</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (_ of [0, 1]; track $index) {
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</table>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
grid-column: span 3;
|
||||
}
|
||||
`,
|
||||
host: { class: 'g-card' },
|
||||
imports: [
|
||||
TuiButton,
|
||||
TableComponent,
|
||||
PlaceholderComponent,
|
||||
i18nPipe,
|
||||
DocsLinkDirective,
|
||||
InterfaceClearnetDomainsItemComponent,
|
||||
TuiSkeleton,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class InterfaceClearnetDomainsComponent {
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly interface = inject(InterfaceComponent)
|
||||
|
||||
readonly clearnetDomains = input.required<
|
||||
readonly ClearnetDomain[] | undefined
|
||||
>()
|
||||
|
||||
private readonly domains = toSignal(
|
||||
this.patch.watch$('serverInfo', 'network', 'domains'),
|
||||
)
|
||||
|
||||
private readonly acme = toSignal(
|
||||
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
|
||||
map(acme =>
|
||||
Object.keys(acme).reduce<Record<string, string>>(
|
||||
(obj, url) => ({
|
||||
...obj,
|
||||
[url]: toAuthorityName(url),
|
||||
}),
|
||||
{ local: toAuthorityName(null) },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
async add() {
|
||||
const addSpec = ISB.InputSpec.of({
|
||||
type: ISB.Value.union({
|
||||
name: 'Type',
|
||||
default: 'public',
|
||||
description:
|
||||
'- **Public**: the domain can be accessed by anyone with an Internet connection.\n- **Private**: the domain can only be accessed by people connected to the same Local Area Network (LAN) as the server, either physically or via VPN.',
|
||||
variants: ISB.Variants.of({
|
||||
public: {
|
||||
name: 'Public',
|
||||
spec: ISB.InputSpec.of({
|
||||
domain: ISB.Value.select({
|
||||
name: 'Domain',
|
||||
default: '',
|
||||
values: Object.keys(this.domains() || {}).reduce<
|
||||
Record<string, string>
|
||||
>(
|
||||
(obj, domain) => ({
|
||||
...obj,
|
||||
[domain]: domain,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
}),
|
||||
subdomain: ISB.Value.text({
|
||||
name: 'Subdomain',
|
||||
description: 'Optionally enter a subdomain',
|
||||
required: false,
|
||||
default: null,
|
||||
patterns: [], // @TODO subdomain pattern
|
||||
}),
|
||||
...this.acmeSpec(true),
|
||||
}),
|
||||
},
|
||||
private: {
|
||||
name: 'Private',
|
||||
spec: ISB.InputSpec.of({
|
||||
fqdn: ISB.Value.text({
|
||||
name: 'Domain',
|
||||
description:
|
||||
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [utils.Patterns.domain],
|
||||
}),
|
||||
...this.acmeSpec(false),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Add domain',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(addSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: async (input: typeof addSpec._TYPE) => {
|
||||
const loader = this.loader.open('Removing').subscribe()
|
||||
const type = input.type.selection
|
||||
const params = {
|
||||
private: type === 'private',
|
||||
fqdn:
|
||||
type === 'public'
|
||||
? `${input.type.value.subdomain}.${input.type.value.domain}`
|
||||
: input.type.value.fqdn,
|
||||
acme:
|
||||
input.type.value.authority === 'local'
|
||||
? null
|
||||
: input.type.value.authority,
|
||||
}
|
||||
try {
|
||||
if (this.interface.packageId()) {
|
||||
await this.api.pkgAddDomain({
|
||||
...params,
|
||||
package: this.interface.packageId(),
|
||||
host: this.interface.value()?.addressInfo.hostId || '',
|
||||
})
|
||||
} else {
|
||||
await this.api.osUiAddDomain(params)
|
||||
}
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private acmeSpec(isPublic: boolean) {
|
||||
return {
|
||||
authority: ISB.Value.select({
|
||||
name: 'Certificate Authority',
|
||||
description:
|
||||
'Select the Certificate Authority that will issue the SSL/TLS certificate for this domain',
|
||||
values: this.acme()!,
|
||||
default: isPublic ? '' : 'local',
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,66 +19,33 @@ import {
|
||||
import { TuiBadge } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { InterfaceComponent } from './interface.component'
|
||||
import { ClearnetDomain } from './interface.service'
|
||||
import { InterfaceComponent } from '../interface.component'
|
||||
import { ClearnetDomain } from '../interface.service'
|
||||
|
||||
@Component({
|
||||
selector: 'tr[domain]',
|
||||
template: `
|
||||
<td>{{ domain().fqdn }}</td>
|
||||
<td>{{ domain().authority || '-' }}</td>
|
||||
<td>{{ domain().authority }}</td>
|
||||
<td>
|
||||
@if (domain().public) {
|
||||
<tui-badge size="s" appearance="primary-success">
|
||||
{{ 'Public' | i18n }}
|
||||
{{ 'public' | i18n }}
|
||||
</tui-badge>
|
||||
} @else {
|
||||
<tui-badge size="s" appearance="primary-destructive">
|
||||
{{ 'Private' | i18n }}
|
||||
{{ 'private' | i18n }}
|
||||
</tui-badge>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
tuiDropdown
|
||||
size="s"
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.ellipsis-vertical"
|
||||
[tuiAppearanceState]="open ? 'hover' : null"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
iconStart="@tui.trash"
|
||||
appearance="action-destructive"
|
||||
(click)="remove()"
|
||||
>
|
||||
{{ 'More' | i18n }}
|
||||
<tui-data-list *tuiTextfieldDropdown>
|
||||
<tui-opt-group>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
[iconStart]="domain().public ? '@tui.eye-off' : '@tui.eye'"
|
||||
(click)="toggle()"
|
||||
>
|
||||
@if (domain().public) {
|
||||
{{ 'Make private' | i18n }}
|
||||
} @else {
|
||||
{{ 'Make public' | i18n }}
|
||||
}
|
||||
</button>
|
||||
<button tuiOption new iconStart="@tui.pencil" (click)="edit()">
|
||||
{{ 'Edit' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.trash"
|
||||
class="g-negative"
|
||||
(click)="remove()"
|
||||
>
|
||||
{{ 'Delete' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
{{ 'Delete' | i18n }}
|
||||
</button>
|
||||
</td>
|
||||
`,
|
||||
@@ -115,7 +82,7 @@ import { ClearnetDomain } from './interface.service'
|
||||
TuiBadge,
|
||||
],
|
||||
})
|
||||
export class DomainComponent {
|
||||
export class InterfaceClearnetDomainsItemComponent {
|
||||
private readonly dialog = inject(DialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
@@ -124,12 +91,6 @@ export class DomainComponent {
|
||||
|
||||
readonly domain = input.required<ClearnetDomain>()
|
||||
|
||||
open = false
|
||||
|
||||
toggle() {}
|
||||
|
||||
edit() {}
|
||||
|
||||
remove() {
|
||||
this.dialog
|
||||
.openConfirm({ label: 'Are you sure?', size: 's' })
|
||||
@@ -3,7 +3,7 @@ import { tuiButtonOptionsProvider } from '@taiga-ui/core'
|
||||
import { MappedServiceInterface } from './interface.service'
|
||||
import { InterfaceGatewaysComponent } from './gateways.component'
|
||||
import { InterfaceTorDomainsComponent } from './tor-domains.component'
|
||||
import { InterfaceClearnetDomainsComponent } from './clearnet-domains.component'
|
||||
import { InterfaceClearnetDomainsComponent } from './clearnet-domains/clearnet-domains.component'
|
||||
import { InterfaceAddressesComponent } from './addresses/addresses.component'
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { T, utils } from '@start9labs/start-sdk'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { toAuthorityName } from 'src/app/utils/acme'
|
||||
import { GatewayPlus } from 'src/app/services/gateway.service'
|
||||
import { DialogService, i18nKey } from '@start9labs/shared'
|
||||
import { i18nKey } from '@start9labs/shared'
|
||||
|
||||
type AddressWithInfo = {
|
||||
url: URL
|
||||
@@ -134,7 +134,9 @@ function toDisplayAddress(
|
||||
]
|
||||
// Tor (HTTP)
|
||||
} else {
|
||||
bullets.unshift('Ideal for anonymous, remote connectivity')
|
||||
bullets.unshift(
|
||||
'Ideal for anonymous, censorship-resistant hosting and remote access',
|
||||
)
|
||||
type = `${type} (HTTP)`
|
||||
}
|
||||
// ** Not Tor **
|
||||
@@ -143,7 +145,7 @@ function toDisplayAddress(
|
||||
const gateway = gateways.find(g => g.id === info.gatewayId)!
|
||||
gatewayName = gateway.ipInfo.name
|
||||
|
||||
const gatewayIpv4 = gateway.ipInfo.subnets[0]
|
||||
const gatewayIpv4 = gateway.ipv4[0]
|
||||
const isWireguard = gateway.ipInfo.deviceType === 'wireguard'
|
||||
|
||||
const localIdeal = 'Ideal for local access'
|
||||
@@ -158,7 +160,7 @@ function toDisplayAddress(
|
||||
access = 'private'
|
||||
bullets = [
|
||||
localIdeal,
|
||||
'Not recommended for VPN access. VPNs do not support ".local" domains without extra configuration',
|
||||
'Not recommended for VPN access. VPNs do not support ".local" domains without advanced configuration',
|
||||
lanRequired,
|
||||
rootCaRequired,
|
||||
]
|
||||
@@ -174,7 +176,7 @@ function toDisplayAddress(
|
||||
]
|
||||
if (!gateway.public) {
|
||||
bullets.push(
|
||||
`Requires creating a port forwarding rule in gateway "${gatewayName}": ${port} -> ${info.hostname.value}:${port}`,
|
||||
`Requires port forwarding in gateway "${gatewayName}": ${port} -> ${info.hostname.value}:${port}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -203,8 +205,8 @@ function toDisplayAddress(
|
||||
if (info.public) {
|
||||
access = 'public'
|
||||
bullets = [
|
||||
`Requires creating DNS records for "${domains[info.hostname.value]?.root}", as shown in System -> Domains`,
|
||||
`Requires creating a port forwarding rule in gateway "${gatewayName}": ${port} -> ${info.hostname.value}:${port === 443 ? 5443 : port}`,
|
||||
`Requires DNS record(s) for ${domains[info.hostname.value]?.root}, as shown in System -> Domains`,
|
||||
`Requires port forwarding in gateway "${gatewayName}": ${port} -> ${info.hostname.value}:${port === 443 ? 5443 : port}`,
|
||||
]
|
||||
if (domain.acme) {
|
||||
bullets.unshift('Ideal for public access via the Internet')
|
||||
@@ -218,7 +220,7 @@ function toDisplayAddress(
|
||||
} else {
|
||||
access = 'private'
|
||||
const ipPortBad = 'when using IP addresses and ports is undesirable'
|
||||
const customDnsRequired = `Requires creating custom DNS records for ${info.hostname.value} that resolve to ${gatewayIpv4}`
|
||||
const customDnsRequired = `Requires DNS record for ${info.hostname.value} that resolve to ${gatewayIpv4}`
|
||||
if (isWireguard) {
|
||||
bullets = [
|
||||
`${vpnAccess} StartTunnel (or similar) ${ipPortBad}`,
|
||||
@@ -463,18 +465,9 @@ export type InterfaceGateway = GatewayPlus & {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// export type InterfaceGateway = {
|
||||
// id: string
|
||||
// name: string
|
||||
// enabled: boolean
|
||||
// public: boolean
|
||||
// type: T.NetworkInterfaceType
|
||||
// lanIpv4: string | null
|
||||
// }
|
||||
|
||||
export type ClearnetDomain = {
|
||||
fqdn: string
|
||||
authority: string | null
|
||||
authority: string
|
||||
public: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -57,10 +57,7 @@ import { MarketplacePkgSideload, validateS9pk } from './sideload.utils'
|
||||
<p>{{ 'Upload .s9pk package file' | i18n }}</p>
|
||||
@if (isTor) {
|
||||
<p class="g-warning">
|
||||
{{
|
||||
'Warning: package upload will be slow over Tor. Switch to local for a better experience.'
|
||||
| i18n
|
||||
}}
|
||||
{{ 'Warning: package upload will be slow over Tor.' | i18n }}
|
||||
</p>
|
||||
}
|
||||
<button tuiButton>{{ 'Upload' | i18n }}</button>
|
||||
|
||||
@@ -1,55 +1,57 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
DialogService,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { MappedDomain } from './domain.service'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
||||
import { parse } from 'tldts'
|
||||
|
||||
// @TODO translations
|
||||
|
||||
@Component({
|
||||
selector: 'dns',
|
||||
template: `
|
||||
<section class="g-card">
|
||||
<header>{{ $any('Using IP') | i18n }}</header>
|
||||
@let wanIp = context.data.gateway.ipInfo?.wanIp || ('Error' | i18n);
|
||||
|
||||
@let subdomain = context.data.subdomain;
|
||||
@let wanIp = context.data.gateway.ipInfo?.wanIp || ('Error' | i18n);
|
||||
<table [appTable]="[$any('Record'), $any('Host'), 'Value', 'Purpose']">
|
||||
<tr>
|
||||
<td>A</td>
|
||||
<td>{{ subdomain() || '@' }}</td>
|
||||
<td>{{ wanIp }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>A</td>
|
||||
<td>{{ subdomain() ? '*.' + subdomain() : '*' }}</td>
|
||||
<td>{{ wanIp }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
|
||||
<table [appTable]="['Type', $any('Host'), 'Value', 'Purpose']">
|
||||
<tr>
|
||||
<td>A</td>
|
||||
<td>{{ subdomain || '@' }}</td>
|
||||
<td>{{ wanIp }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>A</td>
|
||||
<td>{{ subdomain ? '*.' + subdomain : '*' }}</td>
|
||||
<td>{{ wanIp }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@if (context.data.gateway.ipInfo?.deviceType !== 'wireguard') {
|
||||
<section class="g-card">
|
||||
<header>{{ $any('Using Dynamic DNS') | i18n }}</header>
|
||||
<table [appTable]="['Type', $any('Host'), 'Value', 'Purpose']">
|
||||
<tr>
|
||||
<td>ALIAS</td>
|
||||
<td>{{ subdomain || '@' }}</td>
|
||||
<td>[Dynamic DNS Address]</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ALIAS</td>
|
||||
<td>{{ subdomain ? '*.' + subdomain : '*' }}</td>
|
||||
<td>[Dynamic DNS Address]</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
<!-- <tr>
|
||||
<td>ALIAS</td>
|
||||
<td>{{ subdomain() || '@' }}</td>
|
||||
<td>[DDNS Address]</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ALIAS</td>
|
||||
<td>{{ subdomain() ? '*.' + subdomain() : '*' }}</td>
|
||||
<td>[DDNS Address]</td>
|
||||
<td></td>
|
||||
</tr> -->
|
||||
</table>
|
||||
|
||||
<footer class="g-buttons">
|
||||
<button tuiButton size="l" (click)="testDns()">
|
||||
@@ -69,9 +71,12 @@ export class DnsComponent {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly dialog = inject(DialogService)
|
||||
|
||||
readonly context = injectContext<TuiDialogContext<void, MappedDomain>>()
|
||||
|
||||
readonly subdomain = computed(() => parse(this.context.data.fqdn).subdomain)
|
||||
|
||||
async testDns() {
|
||||
const loader = this.loader.open().subscribe()
|
||||
|
||||
@@ -88,6 +93,18 @@ export class DnsComponent {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
description(subdomain: boolean) {
|
||||
const message = subdomain
|
||||
? `This DNS record routes ${this.context.data.fqdn} (no subdomain) to your server.`
|
||||
: `This DNS record routes subdomains of ${this.context.data.fqdn} to your server.`
|
||||
|
||||
this.dialog
|
||||
.openAlert(message as i18nKey, {
|
||||
label: 'Purpose' as i18nKey,
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
export const DNS = new PolymorpheusComponent(DnsComponent)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core'
|
||||
import {
|
||||
DialogService,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
@@ -14,7 +15,6 @@ import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { parse } from 'tldts'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
import { DNS } from './dns.component'
|
||||
|
||||
@@ -22,7 +22,6 @@ import { DNS } from './dns.component'
|
||||
|
||||
export type MappedDomain = {
|
||||
fqdn: string
|
||||
subdomain: string | null
|
||||
gateway: {
|
||||
id: string
|
||||
name: string | null
|
||||
@@ -30,6 +29,13 @@ export type MappedDomain = {
|
||||
}
|
||||
}
|
||||
|
||||
type GatewayWithId = T.NetworkInterfaceInfo & {
|
||||
id: string
|
||||
ipInfo: T.IpInfo & {
|
||||
wanIp: string
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DomainService {
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
@@ -43,18 +49,13 @@ export class DomainService {
|
||||
readonly data = toSignal(
|
||||
this.patch.watch$('serverInfo', 'network').pipe(
|
||||
map(({ gateways, domains }) => ({
|
||||
gateways: Object.entries(gateways).reduce<Record<string, string>>(
|
||||
(obj, [id, n]) => ({
|
||||
...obj,
|
||||
[id]: n.ipInfo?.name || '',
|
||||
}),
|
||||
{},
|
||||
),
|
||||
gateways: Object.entries(gateways)
|
||||
.filter(([_, g]) => g.ipInfo && g.ipInfo.wanIp)
|
||||
.map(([id, g]) => ({ id, ...g })) as GatewayWithId[],
|
||||
domains: Object.entries(domains).map(
|
||||
([fqdn, { gateway }]) =>
|
||||
({
|
||||
fqdn,
|
||||
subdomain: parse(fqdn).subdomain,
|
||||
gateway: {
|
||||
id: gateway,
|
||||
ipInfo: gateways[gateway]?.ipInfo || null,
|
||||
@@ -70,7 +71,7 @@ export class DomainService {
|
||||
fqdn: ISB.Value.text({
|
||||
name: 'Domain',
|
||||
description:
|
||||
'Enter a domain/subdomain. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com. In any case, the domain you enter and all possible subdomains of the domain will be available for assignment in StartOS',
|
||||
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com. In any case, the domain you enter and all possible subdomains of the domain will be available for assignment in StartOS',
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [utils.Patterns.domain],
|
||||
@@ -141,7 +142,7 @@ export class DomainService {
|
||||
|
||||
showDns(domain: MappedDomain) {
|
||||
this.dialog
|
||||
.openComponent(DNS, { label: 'Manage DNS', data: domain })
|
||||
.openComponent(DNS, { label: 'DNS Records' as i18nKey, data: domain })
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
@@ -160,13 +161,24 @@ export class DomainService {
|
||||
}
|
||||
|
||||
private gatewaysSpec() {
|
||||
const gateways = this.data()?.gateways || []
|
||||
|
||||
return {
|
||||
gateway: ISB.Value.select({
|
||||
gateway: ISB.Value.dynamicSelect(() => ({
|
||||
name: 'Gateway',
|
||||
description: 'Select which gateway to use for this domain.',
|
||||
values: this.data()!.gateways,
|
||||
description: 'Select a gateway to use for this domain.',
|
||||
values: gateways.reduce<Record<string, string>>(
|
||||
(obj, gateway) => ({
|
||||
...obj,
|
||||
[gateway.id]: gateway.ipInfo!.name,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
default: '',
|
||||
}),
|
||||
disabled: gateways
|
||||
.filter(g => g.ipInfo.wanIp.split('.').at(-1) === '100')
|
||||
.map(g => g.id),
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ import { DomainsTableComponent } from './table.component'
|
||||
<domains-table />
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
max-width: 50rem;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
TuiButton,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { DomainService, MappedDomain } from './domain.service'
|
||||
template: `
|
||||
@if (domain(); as domain) {
|
||||
<td>{{ domain.fqdn }}</td>
|
||||
<td [style.order]="-1">{{ domain.gateway.ipInfo?.name || '-' }}</td>
|
||||
<td>{{ domain.gateway.ipInfo?.name || '-' }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
@@ -35,18 +35,18 @@ import { DomainService, MappedDomain } from './domain.service'
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.pencil"
|
||||
(click)="domainService.edit(domain)"
|
||||
iconStart="@tui.eye"
|
||||
(click)="domainService.showDns(domain)"
|
||||
>
|
||||
{{ 'Edit' | i18n }}
|
||||
{{ 'View DNS' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.eye"
|
||||
(click)="domainService.showDns(domain)"
|
||||
iconStart="@tui.pencil"
|
||||
(click)="domainService.edit(domain)"
|
||||
>
|
||||
{{ 'Manage DNS' | i18n }}
|
||||
{{ 'Edit' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DomainService } from './domain.service'
|
||||
<tr [domain]="domain"></tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td [attr.colspan]="4">
|
||||
<td [attr.colspan]="3">
|
||||
@if (domainService.data()?.domains) {
|
||||
<app-placeholder icon="@tui.globe">
|
||||
{{ 'No domains' | i18n }}
|
||||
|
||||
@@ -30,9 +30,14 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
|
||||
template: `
|
||||
@if (gateway(); as gateway) {
|
||||
<td [style.grid-column]="'span 2'">{{ gateway.ipInfo.name }}</td>
|
||||
<td class="type">{{ gateway.ipInfo.deviceType || '-' }}</td>
|
||||
<td [style.order]="-2">
|
||||
{{ gateway.public ? ('Public' | i18n) : ('Private' | i18n) }}
|
||||
<td class="type">
|
||||
@if (gateway.ipInfo.deviceType; as type) {
|
||||
{{ type }} ({{
|
||||
gateway.public ? ('public' | i18n) : ('private' | i18n)
|
||||
}})
|
||||
} @else {
|
||||
-
|
||||
}
|
||||
</td>
|
||||
<td class="lan">{{ gateway.ipv4.join(', ') }}</td>
|
||||
<td
|
||||
@@ -89,15 +94,8 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
|
||||
.type {
|
||||
grid-column: span 2;
|
||||
order: -1;
|
||||
|
||||
&::before {
|
||||
content: '\\00A0(';
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: ')';
|
||||
}
|
||||
}
|
||||
|
||||
.lan,
|
||||
|
||||
@@ -8,16 +8,7 @@ import { GatewayService } from 'src/app/services/gateway.service'
|
||||
@Component({
|
||||
selector: 'gateways-table',
|
||||
template: `
|
||||
<table
|
||||
[appTable]="[
|
||||
'Name',
|
||||
'Type',
|
||||
'Access',
|
||||
$any('LAN IP'),
|
||||
$any('WAN IP'),
|
||||
null,
|
||||
]"
|
||||
>
|
||||
<table [appTable]="['Name', 'Type', $any('LAN IP'), $any('WAN IP'), null]">
|
||||
@for (gateway of gatewayService.gateways(); track $index) {
|
||||
<tr [gateway]="gateway"></tr>
|
||||
} @empty {
|
||||
|
||||
@@ -70,36 +70,34 @@ import UpdatesComponent from './updates.component'
|
||||
<td class="desktop">{{ item().gitHash }}</td>
|
||||
<td class="desktop">{{ item().s9pk.publishedAt | date }}</td>
|
||||
<td>
|
||||
<div>
|
||||
<button
|
||||
tuiIconButton
|
||||
size="m"
|
||||
appearance="icon"
|
||||
[tuiChevron]="expanded()"
|
||||
>
|
||||
{{ 'Show more' | i18n }}
|
||||
</button>
|
||||
@if (local().stateInfo.state === 'updating') {
|
||||
<tui-progress-circle
|
||||
size="xs"
|
||||
[max]="100"
|
||||
[value]="
|
||||
(local().stateInfo.installingInfo?.progress?.overall
|
||||
| installingProgress) || 0
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<button
|
||||
tuiIconButton
|
||||
size="m"
|
||||
appearance="icon"
|
||||
[tuiChevron]="expanded()"
|
||||
tuiButton
|
||||
size="s"
|
||||
[loading]="!ready()"
|
||||
[appearance]="error() ? 'destructive' : 'primary'"
|
||||
(click.stop)="onClick()"
|
||||
>
|
||||
{{ 'Show more' | i18n }}
|
||||
{{ error() ? ('Retry' | i18n) : ('Update' | i18n) }}
|
||||
</button>
|
||||
@if (local().stateInfo.state === 'updating') {
|
||||
<tui-progress-circle
|
||||
size="xs"
|
||||
[max]="100"
|
||||
[value]="
|
||||
(local().stateInfo.installingInfo?.progress?.overall
|
||||
| installingProgress) || 0
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
size="s"
|
||||
[loading]="!ready()"
|
||||
[appearance]="error() ? 'destructive' : 'primary'"
|
||||
(click.stop)="onClick()"
|
||||
>
|
||||
{{ error() ? ('Retry' | i18n) : ('Update' | i18n) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
Reference in New Issue
Block a user