forms for adding domain, rework things based on new ideas

This commit is contained in:
Matt Hill
2025-08-10 23:33:05 -06:00
parent 022f7134be
commit 68780ccbdd
23 changed files with 430 additions and 329 deletions

View File

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

View File

@@ -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]',

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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({

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,11 @@ import { DomainsTableComponent } from './table.component'
<domains-table />
</section>
`,
styles: `
:host {
max-width: 50rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,

View File

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

View File

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

View File

@@ -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,

View File

@@ -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 {

View File

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