looking good

This commit is contained in:
Matt Hill
2026-02-14 16:37:04 -07:00
parent 3a63f3b840
commit 2f19188dae
22 changed files with 4009 additions and 6738 deletions

View File

@@ -29,6 +29,16 @@ import { GatewayAddress, MappedServiceInterface } from '../interface.service'
selector: 'td[actions]',
template: `
<div class="desktop">
@if (address().deletable) {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.trash"
(click)="deleteDomain()"
>
{{ 'Delete' | i18n }}
</button>
}
<button
tuiIconButton
appearance="flat-grayscale"
@@ -45,16 +55,6 @@ import { GatewayAddress, MappedServiceInterface } from '../interface.service'
>
{{ 'Copy URL' | i18n }}
</button>
@if (address().deletable) {
<button
tuiIconButton
appearance="flat-destructive"
iconStart="@tui.trash"
(click)="deleteDomain()"
>
{{ 'Delete' | i18n }}
</button>
}
</div>
<div class="mobile">
<button
@@ -70,17 +70,14 @@ import { GatewayAddress, MappedServiceInterface } from '../interface.service'
<button
tuiOption
new
[iconStart]="address().enabled ? '@tui.toggle-right' : '@tui.toggle-left'"
[iconStart]="
address().enabled ? '@tui.toggle-right' : '@tui.toggle-left'
"
(click)="toggleEnabled()"
>
{{ (address().enabled ? 'Disable' : 'Enable') | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.qr-code"
(click)="showQR()"
>
<button tuiOption new iconStart="@tui.qr-code" (click)="showQR()">
{{ 'Show QR' | i18n }}
</button>
<button

View File

@@ -8,7 +8,6 @@ import {
import {
DialogService,
ErrorService,
i18nKey,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
@@ -36,7 +35,7 @@ import {
GatewayAddressGroup,
MappedServiceInterface,
} from '../interface.service'
import { DNS, DnsGateway } from '../public-domains/dns.component'
import { DOMAIN_VALIDATION, DnsGateway } from '../public-domains/dns.component'
import { InterfaceAddressItemComponent } from './item.component'
@Component({
@@ -62,7 +61,16 @@ import { InterfaceAddressItemComponent } from './item.component'
</tui-data-list>
</button>
</header>
<table [appTable]="['Enabled', 'Type', 'Access', 'URL', null]">
<table
[appTable]="[
'Enabled',
'Type',
'Access',
'Certificate Authority',
'URL',
null,
]"
>
@for (address of gatewayGroup().addresses; track $index) {
<tr
[address]="address"
@@ -72,7 +80,7 @@ import { InterfaceAddressItemComponent } from './item.component'
></tr>
} @empty {
<tr>
<td colspan="5">
<td colspan="6">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
@@ -86,6 +94,12 @@ import { InterfaceAddressItemComponent } from './item.component'
th:first-child {
width: 5rem;
}
th:nth-child(2),
th:nth-child(3),
th:nth-child(4) {
width: 11rem;
}
}
`,
host: { class: 'g-card' },
@@ -120,6 +134,7 @@ export class InterfaceAddressesComponent {
async addPrivateDomain() {
this.formDialog.open<FormContext<{ fqdn: string }>>(FormComponent, {
label: 'New private domain',
size: 's',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
@@ -173,22 +188,19 @@ export class InterfaceAddressesComponent {
default: null,
patterns: [utils.Patterns.domain],
}).map(f => f.toLocaleLowerCase()),
...(iface.addSsl
? {
authority: ISB.Value.select({
name: this.i18n.transform('Certificate Authority'),
description: this.i18n.transform(
'Select a Certificate Authority to issue SSL/TLS certificates for this domain',
),
values: authorities,
default: '',
}),
}
: ({} as { authority: ReturnType<typeof ISB.Value.select> })),
authority: ISB.Value.select({
name: this.i18n.transform('Certificate Authority'),
description: this.i18n.transform(
'Select a Certificate Authority to issue SSL/TLS certificates for this domain',
),
values: authorities,
default: Object.keys(network.acme)[0] || 'local',
}),
})
this.formDialog.open(FormComponent, {
label: 'Add public domain',
size: 's',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
@@ -251,55 +263,33 @@ export class InterfaceAddressesComponent {
ip = await this.api.osUiAddPublicDomain(params)
}
const network = await this.patch
.watch$('serverInfo', 'network')
.pipe()
.toPromise()
const gateway = network?.gateways[gatewayId]
const [network, portPass] = await Promise.all([
firstValueFrom(this.patch.watch$('serverInfo', 'network')),
this.api
.testPortForward({ gateway: gatewayId, port: 443 })
.catch(() => false),
])
const gateway = network.gateways[gatewayId]
if (gateway?.ipInfo) {
const wanIp = gateway.ipInfo.wanIp
const message = this.i18n.transform(
'Create one of the DNS records below.',
) as i18nKey
const gatewayData = {
id: gatewayId,
...gateway,
ipInfo: gateway.ipInfo,
}
const dnsPass = ip === gateway.ipInfo.wanIp
if (!ip) {
setTimeout(
() =>
this.showDns(
fqdn,
gatewayData,
`${this.i18n.transform('No DNS record detected for')} ${fqdn}. ${message}` as i18nKey,
),
250,
)
} else if (ip !== wanIp) {
setTimeout(
() =>
this.showDns(
fqdn,
gatewayData,
`${this.i18n.transform('Invalid DNS record')}. ${fqdn} ${this.i18n.transform('resolves to')} ${ip}. ${message}` as i18nKey,
),
250,
)
} else {
setTimeout(
() =>
this.dialog
.openAlert(
`${fqdn} ${this.i18n.transform('resolves to')} ${wanIp}` as i18nKey,
{ label: 'DNS record detected!', appearance: 'positive' },
)
.subscribe(),
250,
)
}
setTimeout(
() =>
this.showDomainValidation(
fqdn,
gatewayData,
443,
dnsPass,
portPass,
),
250,
)
}
return true
@@ -311,12 +301,18 @@ export class InterfaceAddressesComponent {
}
}
private showDns(fqdn: string, gateway: DnsGateway, message: i18nKey) {
private showDomainValidation(
fqdn: string,
gateway: DnsGateway,
port: number,
dnsPass: boolean,
portPass: boolean,
) {
this.dialog
.openComponent(DNS, {
label: 'DNS Records',
size: 'l',
data: { fqdn, gateway, message },
.openComponent(DOMAIN_VALIDATION, {
label: 'Domain Setup',
size: 'm',
data: { fqdn, gateway, port, dnsPass, portPass },
})
.subscribe()
}

View File

@@ -8,8 +8,7 @@ import {
} from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiObfuscatePipe } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { FormsModule } from '@angular/forms'
import { TuiSwitch } from '@taiga-ui/kit'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@@ -18,6 +17,9 @@ import { AddressActionsComponent } from './actions.component'
@Component({
selector: 'tr[address]',
host: {
'[class._disabled]': '!address().enabled',
},
template: `
@if (address(); as address) {
<td>
@@ -26,6 +28,7 @@ import { AddressActionsComponent } from './actions.component'
tuiSwitch
size="s"
[showIcons]="false"
[disabled]="toggling()"
[ngModel]="address.enabled"
(ngModelChange)="onToggleEnabled()"
/>
@@ -33,18 +36,16 @@ import { AddressActionsComponent } from './actions.component'
<td>
{{ address.type }}
</td>
<td>
@if (address.access === 'public') {
<tui-badge size="s" appearance="primary-success">
{{ 'public' | i18n }}
</tui-badge>
} @else {
<tui-badge size="s" appearance="primary-destructive">
{{ 'private' | i18n }}
</tui-badge>
}
<td class="access">
<tui-icon
[icon]="address.access === 'public' ? '@tui.globe' : '@tui.house'"
/>
{{ address.access | i18n }}
</td>
<td [style.grid-area]="'2 / 1 / 2 / 3'">
<td>
{{ address.certificate }}
</td>
<td>
<div class="url">
<span
[title]="address.masked && currentlyMasked() ? '' : address.url"
@@ -79,6 +80,11 @@ import { AddressActionsComponent } from './actions.component'
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
}
.access tui-icon {
font-size: 1rem;
vertical-align: middle;
}
.url {
display: flex;
align-items: center;
@@ -95,6 +101,23 @@ import { AddressActionsComponent } from './actions.component'
}
:host-context(tui-root._mobile) {
padding-inline-start: 0.75rem !important;
&::before {
content: '';
position: absolute;
inset-inline-start: 0;
top: 0.25rem;
bottom: 0.25rem;
width: 4px;
background: var(--tui-status-positive);
border-radius: 2px;
}
&._disabled::before {
background: var(--tui-background-neutral-1-hover);
}
td {
width: auto !important;
align-content: center;
@@ -110,13 +133,27 @@ import { AddressActionsComponent } from './actions.component'
color: var(--tui-text-primary);
padding-inline-end: 0.5rem;
}
td:nth-child(4) {
grid-area: 2 / 1 / 2 / 3;
}
td:nth-child(5) {
grid-area: 3 / 1 / 3 / 3;
}
td:last-child {
grid-area: 1 / 3 / 4 / 5;
align-self: center;
justify-self: end;
}
}
`,
imports: [
i18nPipe,
AddressActionsComponent,
TuiBadge,
TuiButton,
TuiIcon,
TuiObfuscatePipe,
TuiSwitch,
FormsModule,
@@ -125,14 +162,15 @@ import { AddressActionsComponent } from './actions.component'
})
export class InterfaceAddressItemComponent {
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
readonly address = input.required<GatewayAddress>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly isRunning = input.required<boolean>()
readonly toggling = signal(false)
readonly currentlyMasked = signal(true)
readonly recipe = computed(() =>
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
@@ -143,6 +181,7 @@ export class InterfaceAddressItemComponent {
const iface = this.value()
if (!iface) return
this.toggling.set(true)
const enabled = !addr.enabled
const addressJson = JSON.stringify(addr.hostnameInfo)
const loader = this.loader.open('Saving').subscribe()
@@ -167,6 +206,7 @@ export class InterfaceAddressItemComponent {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
this.toggling.set(false)
}
}
}

View File

@@ -2,11 +2,10 @@ import { inject, Injectable } from '@angular/core'
import { T, utils } from '@start9labs/start-sdk'
import { ConfigService } from 'src/app/services/config.service'
import { GatewayPlus } from 'src/app/services/gateway.service'
import { toAuthorityName } from 'src/app/utils/acme'
function isPublicIp(h: T.HostnameInfo): boolean {
return (
h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
)
return h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
}
function isEnabled(addr: T.DerivedAddressInfo, h: T.HostnameInfo): boolean {
@@ -38,6 +37,33 @@ function getGatewayIds(h: T.HostnameInfo): string[] {
}
}
function getCertificate(
h: T.HostnameInfo,
host: T.Host,
addSsl: T.AddSslOptions | null,
secure: T.Security | null,
): string {
if (!h.ssl) return '-'
if (h.metadata.kind === 'public-domain') {
const config = host.publicDomains[h.host]
return config ? toAuthorityName(config.acme) : toAuthorityName(null)
}
if (addSsl) return toAuthorityName(null)
if (secure?.ssl) return 'Self signed'
return '-'
}
function sortDomainsFirst(a: GatewayAddress, b: GatewayAddress): number {
const isDomain = (addr: GatewayAddress) =>
addr.hostnameInfo.metadata.kind === 'public-domain' ||
(addr.hostnameInfo.metadata.kind === 'private-domain' &&
!addr.hostnameInfo.host.endsWith('.local'))
return Number(isDomain(b)) - Number(isDomain(a))
}
function getAddressType(h: T.HostnameInfo): string {
switch (h.metadata.kind) {
case 'ipv4':
@@ -66,46 +92,40 @@ export class InterfaceService {
host: T.Host,
gateways: GatewayPlus[],
): GatewayAddressGroup[] {
const binding =
host.bindings[serviceInterface.addressInfo.internalPort]
const binding = host.bindings[serviceInterface.addressInfo.internalPort]
if (!binding) return []
const addr = binding.addresses
const masked = serviceInterface.masked
const ui = serviceInterface.type === 'ui'
const { addSsl, secure } = binding.options
const groupMap = new Map<string, GatewayAddress[]>()
const gatewayMap = new Map<string, GatewayPlus>()
for (const gateway of gateways) {
groupMap.set(gateway.id, [])
gatewayMap.set(gateway.id, gateway)
}
for (const h of addr.available) {
const enabled = isEnabled(addr, h)
const url = utils.addressHostToUrl(serviceInterface.addressInfo, h)
const type = getAddressType(h)
const isDomain =
h.metadata.kind === 'private-domain' ||
h.metadata.kind === 'public-domain'
const isMdns = h.metadata.kind === 'mdns'
const address: GatewayAddress = {
enabled,
type,
access: h.public ? 'public' : 'private',
url,
hostnameInfo: h,
masked,
ui,
deletable: isDomain && !isMdns,
}
const gatewayIds = getGatewayIds(h)
for (const gid of gatewayIds) {
const list = groupMap.get(gid)
if (list) {
list.push(address)
}
if (!list) continue
list.push({
enabled: isEnabled(addr, h),
type: getAddressType(h),
access: h.public ? 'public' : 'private',
url: utils.addressHostToUrl(serviceInterface.addressInfo, h),
hostnameInfo: h,
masked,
ui,
deletable:
h.metadata.kind === 'private-domain' ||
h.metadata.kind === 'public-domain',
certificate: getCertificate(h, host, addSsl, secure),
})
}
}
@@ -114,7 +134,7 @@ export class InterfaceService {
.map(g => ({
gatewayId: g.id,
gatewayName: g.name,
addresses: groupMap.get(g.id)!,
addresses: groupMap.get(g.id)!.sort(sortDomainsFirst),
}))
}
@@ -122,8 +142,7 @@ export class InterfaceService {
serviceInterface: T.ServiceInterface,
host: T.Host,
): PluginAddressGroup[] {
const binding =
host.bindings[serviceInterface.addressInfo.internalPort]
const binding = host.bindings[serviceInterface.addressInfo.internalPort]
if (!binding) return []
const addr = binding.addresses
@@ -224,6 +243,7 @@ export type GatewayAddress = {
masked: boolean
ui: boolean
deletable: boolean
certificate: string
}
export type GatewayAddressGroup = {

View File

@@ -24,47 +24,115 @@ export type DnsGateway = T.NetworkInterfaceInfo & {
ipInfo: T.IpInfo
}
@Component({
selector: 'dns',
template: `
<p>{{ context.data.message }}</p>
export type DomainValidationData = {
fqdn: string
gateway: DnsGateway
port: number
dnsPass: boolean
portPass: boolean
}
@Component({
selector: 'domain-validation',
template: `
@let wanIp = context.data.gateway.ipInfo.wanIp || ('Error' | i18n);
@let gatewayName =
context.data.gateway.name || context.data.gateway.ipInfo.name;
@let internalIp = context.data.gateway.ipInfo.lanIp[0] || ('Error' | i18n);
<h3>{{ 'DNS' | i18n }}</h3>
<p>
{{ 'In your domain registrar for' | i18n }} {{ domain }},
{{ 'create this DNS record' | i18n }}
</p>
@if (context.data.gateway.ipInfo.deviceType !== 'wireguard') {
<label>
IP
<input
type="checkbox"
appearance="flat"
tuiSwitch
[(ngModel)]="ddns"
(ngModelChange)="pass.set(undefined)"
(ngModelChange)="dnsPass.set(undefined)"
/>
{{ 'Dynamic DNS' | i18n }}
</label>
}
<table [appTable]="['Type', 'Host', 'Value', 'Purpose']">
@for (row of rows(); track $index) {
<tr>
<td>
@if (pass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (pass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
}
{{ ddns ? 'ALIAS' : 'A' }}
</td>
<td>{{ row.host }}</td>
<td>{{ ddns ? '[DDNS Address]' : wanIp }}</td>
<td>{{ row.purpose }}</td>
</tr>
}
<table [appTable]="[null, 'Type', 'Host', 'Value', null]">
<tr>
<td class="status">
@if (dnsPass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (dnsPass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
}
</td>
<td>{{ ddns ? 'ALIAS' : 'A' }}</td>
<td>*</td>
<td>{{ ddns ? '[DDNS Address]' : wanIp }}</td>
<td>
<button
tuiButton
size="s"
[loading]="dnsLoading()"
(click)="testDns()"
>
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
<h3>{{ 'Port Forwarding' | i18n }}</h3>
<p>
{{ 'In your gateway' | i18n }} "{{ gatewayName }}",
{{ 'create this port forwarding rule' | i18n }}
</p>
<table
[appTable]="[null, 'External Port', 'Internal IP', 'Internal Port', null]"
>
<tr>
<td class="status">
@if (portPass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (portPass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
}
</td>
<td>{{ context.data.port }}</td>
<td>{{ internalIp }}</td>
<td>{{ context.data.port }}</td>
<td>
<button
tuiButton
size="s"
[loading]="portLoading()"
(click)="testPort()"
>
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
<footer class="g-buttons">
<button tuiButton [loading]="loading()" (click)="testDns()">
{{ 'Test' | i18n }}
<button
tuiButton
appearance="flat"
[disabled]="allPass()"
(click)="context.completeWith()"
>
{{ 'Later' | i18n }}
</button>
<button
tuiButton
[disabled]="!allPass()"
(click)="context.completeWith()"
>
{{ 'Done' | i18n }}
</button>
</footer>
`,
@@ -76,14 +144,57 @@ export type DnsGateway = T.NetworkInterfaceInfo & {
margin: 1rem 0;
}
h3 {
margin: 1.5rem 0 0.5rem;
&:first-child {
margin-top: 0;
}
}
tui-icon {
font-size: 1rem;
vertical-align: text-bottom;
}
.status {
width: 1.5rem;
}
td:last-child {
text-align: end;
}
footer {
margin-top: 1.5rem;
}
:host-context(tui-root._mobile) table {
thead {
display: table-header-group !important;
}
tr {
display: table-row !important;
box-shadow: none !important;
}
td,
th {
padding: 0.5rem 0.5rem !important;
font: var(--tui-font-text-s) !important;
color: var(--tui-text-primary) !important;
font-weight: normal !important;
}
th {
font-weight: bold !important;
}
}
`,
providers: [
tuiSwitchOptionsProvider({
appearance: () => 'primary',
appearance: () => 'glass',
icon: () => '',
}),
],
@@ -98,75 +209,61 @@ export type DnsGateway = T.NetworkInterfaceInfo & {
TuiIcon,
],
})
export class DnsComponent {
export class DomainValidationComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
readonly ddns = false
readonly context =
injectContext<
TuiDialogContext<
void,
{ fqdn: string; gateway: DnsGateway; message: string }
>
>()
injectContext<TuiDialogContext<void, DomainValidationData>>()
readonly loading = signal(false)
readonly pass = signal<boolean | undefined>(undefined)
readonly domain =
parse(this.context.data.fqdn).domain || this.context.data.fqdn
readonly rows = computed<{ host: string; purpose: string }[]>(() => {
const { domain, subdomain } = parse(this.context.data.fqdn)
readonly dnsLoading = signal(false)
readonly portLoading = signal(false)
readonly dnsPass = signal<boolean | undefined>(this.context.data.dnsPass)
readonly portPass = signal<boolean | undefined>(this.context.data.portPass)
if (!subdomain) {
return [
{
host: '@',
purpose: domain!,
},
]
}
const segments = subdomain.split('.').slice(1)
const subdomains = this.i18n.transform('all subdomains of')
return [
{
host: subdomain,
purpose: `only ${subdomain}`,
},
...segments.map((_, i) => {
const parent = segments.slice(i).join('.')
return {
host: `*.${parent}`,
purpose: `${subdomains} ${parent}`,
}
}),
{
host: '*',
purpose: `${subdomains} ${domain}`,
},
]
})
readonly allPass = computed(
() => this.dnsPass() === true && this.portPass() === true,
)
async testDns() {
this.pass.set(undefined)
this.loading.set(true)
this.dnsLoading.set(true)
try {
const ip = await this.api.queryDns({
fqdn: this.context.data.fqdn,
})
this.pass.set(ip === this.context.data.gateway.ipInfo.wanIp)
this.dnsPass.set(ip === this.context.data.gateway.ipInfo.wanIp)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
this.dnsLoading.set(false)
}
}
async testPort() {
this.portLoading.set(true)
try {
const result = await this.api.testPortForward({
gateway: this.context.data.gateway.id,
port: this.context.data.port,
})
this.portPass.set(result)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.portLoading.set(false)
}
}
}
export const DNS = new PolymorpheusComponent(DnsComponent)
export const DOMAIN_VALIDATION = new PolymorpheusComponent(
DomainValidationComponent,
)

View File

@@ -1,5 +1,7 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { T } from '@start9labs/start-sdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
@Component({
@@ -7,21 +9,26 @@ import { TuiBadge } from '@taiga-ui/kit'
template: `
<td><ng-content /></td>
<td>
<tui-badge size="m" [appearance]="appearance">{{ info.type }}</tui-badge>
<tui-badge size="m" [appearance]="appearance">
{{ info().type }}
</tui-badge>
</td>
<td class="g-secondary" [style.grid-area]="'2 / 1 / 2 / 3'">
{{ info.description }}
{{ info().description }}
</td>
<td class="actions">
<a
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.settings"
size="s"
[routerLink]="link()"
>
Settings
</a>
</td>
`,
styles: `
:host {
cursor: pointer;
&:hover {
background: var(--tui-background-neutral-1);
}
}
td:first-child {
white-space: nowrap;
}
@@ -35,9 +42,13 @@ import { TuiBadge } from '@taiga-ui/kit'
font-size: 1rem;
}
.actions {
text-align: end;
}
:host-context(tui-root._mobile) {
display: grid;
grid-template-columns: min-content;
grid-template-columns: 1fr auto;
align-items: center;
padding: 1rem 0.5rem;
gap: 0.5rem;
@@ -45,17 +56,21 @@ import { TuiBadge } from '@taiga-ui/kit'
td {
padding: 0;
}
.actions {
grid-area: 1 / 2 / 3 / 3;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiBadge],
imports: [TuiBadge, TuiButton, RouterLink],
})
export class ServiceInterfaceItemComponent {
@Input({ required: true })
info!: T.ServiceInterface
readonly info = input.required<T.ServiceInterface>()
readonly link = input.required<string>()
get appearance(): string {
switch (this.info.type) {
switch (this.info().type) {
case 'ui':
return 'positive'
case 'api':

View File

@@ -4,7 +4,6 @@ import {
computed,
input,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { TuiTable } from '@taiga-ui/addon-table'
import { tuiDefaultSort } from '@taiga-ui/cdk'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@@ -22,19 +21,13 @@ import { PlaceholderComponent } from '../../../components/placeholder.component'
<th tuiTh>{{ 'Name' | i18n }}</th>
<th tuiTh>{{ 'Type' | i18n }}</th>
<th tuiTh>{{ 'Description' | i18n }}</th>
<th tuiTh></th>
</tr>
</thead>
<tbody>
@for (info of interfaces(); track $index) {
<tr
tabindex="-1"
serviceInterface
[info]="info"
[routerLink]="info.routerLink"
>
<a [routerLink]="info.routerLink">
<strong>{{ info.name }}</strong>
</a>
<tr serviceInterface [info]="info" [link]="info.routerLink">
<strong>{{ info.name }}</strong>
</tr>
} @empty {
<app-placeholder icon="@tui.monitor-x">
@@ -56,7 +49,6 @@ import { PlaceholderComponent } from '../../../components/placeholder.component'
TuiTable,
i18nPipe,
PlaceholderComponent,
RouterLink,
],
})
export class ServiceInterfacesComponent {

View File

@@ -7,7 +7,7 @@ import { AuthorityService } from './authority.service'
selector: 'authorities-table',
template: `
<table [appTable]="['Provider', 'URL', 'Contact', null]">
<tr [authority]="{ name: 'Local Root CA' }"></tr>
<tr [authority]="{ name: 'Root CA' }"></tr>
@for (authority of authorityService.authorities(); track $index) {
<tr [authority]="authority"></tr>
}

View File

@@ -120,6 +120,12 @@ export namespace RR {
} // net.dns.query
export type QueryDnsRes = string | null
export type TestPortForwardReq = {
gateway: string
port: number
} // net.port-forward.test
export type TestPortForwardRes = boolean
export type SetKeyboardReq = FullKeyboard // server.set-keyboard
export type SetKeyboardRes = null

View File

@@ -1,5 +1,3 @@
import { MarketplacePkg } from '@start9labs/marketplace'
import { T } from '@start9labs/start-sdk'
import { RR } from './api.types'
import { WebSocketSubject } from 'rxjs/webSocket'
@@ -119,6 +117,10 @@ export abstract class ApiService {
abstract queryDns(params: RR.QueryDnsReq): Promise<RR.QueryDnsRes>
abstract testPortForward(
params: RR.TestPortForwardReq,
): Promise<RR.TestPortForwardRes>
// smtp
abstract setSmtp(params: RR.SetSMTPReq): Promise<RR.SetSMTPRes>

View File

@@ -1,6 +1,5 @@
import { DOCUMENT, Inject, Injectable } from '@angular/core'
import { blake3 } from '@noble/hashes/blake3'
import { MarketplacePkg } from '@start9labs/marketplace'
import {
HttpOptions,
HttpService,
@@ -8,7 +7,6 @@ import {
RpcError,
RPCOptions,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { Dump, pathFromArray } from 'patch-db-client'
import { filter, firstValueFrom, Observable } from 'rxjs'
import { webSocket, WebSocketSubject } from 'rxjs/webSocket'
@@ -268,6 +266,15 @@ export class LiveApiService extends ApiService {
})
}
async testPortForward(
params: RR.TestPortForwardReq,
): Promise<RR.TestPortForwardRes> {
return this.rpcRequest({
method: 'net.port-forward.test',
params,
})
}
// marketplace URLs
async checkOSUpdate(
@@ -358,7 +365,10 @@ export class LiveApiService extends ApiService {
async setDefaultOutbound(
params: RR.SetDefaultOutboundReq,
): Promise<RR.SetDefaultOutboundRes> {
return this.rpcRequest({ method: 'net.gateway.set-default-outbound', params })
return this.rpcRequest({
method: 'net.gateway.set-default-outbound',
params,
})
}
async setServiceOutbound(

View File

@@ -483,6 +483,14 @@ export class MockApiService extends ApiService {
return null
}
async testPortForward(
params: RR.TestPortForwardReq,
): Promise<RR.TestPortForwardRes> {
await pauseFor(2000)
return false
}
// marketplace URLs
async checkOSUpdate(
@@ -589,7 +597,7 @@ export class MockApiService extends ApiService {
]
if (params.setAsDefaultOutbound) {
;(patch as any[]).push({
(patch as any[]).push({
op: PatchOp.REPLACE,
path: '/serverInfo/network/defaultOutbound',
value: id,
@@ -1394,7 +1402,9 @@ export class MockApiService extends ApiService {
): Promise<RR.ServerBindingSetAddressEnabledRes> {
await pauseFor(2000)
// Mock: no-op since address enable/disable modifies DerivedAddressInfo sets
const basePath = `/serverInfo/network/host/bindings/${params.internalPort}/addresses`
this.mockSetAddressEnabled(basePath, params.address, params.enabled)
return null
}
@@ -1493,7 +1503,9 @@ export class MockApiService extends ApiService {
): Promise<RR.PkgBindingSetAddressEnabledRes> {
await pauseFor(2000)
// Mock: no-op since address enable/disable modifies DerivedAddressInfo sets
const basePath = `/packageData/${params.package}/hosts/${params.host}/bindings/${params.internalPort}/addresses`
this.mockSetAddressEnabled(basePath, params.address, params.enabled)
return null
}
@@ -1813,6 +1825,63 @@ export class MockApiService extends ApiService {
}, 1000)
}
private mockSetAddressEnabled(
basePath: string,
addressJson: string,
enabled: boolean | null,
): void {
const h: T.HostnameInfo = JSON.parse(addressJson)
const isPublicIp =
h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
const current = this.mockData(basePath) as T.DerivedAddressInfo
if (isPublicIp) {
if (h.port === null) return
const sa =
h.metadata.kind === 'ipv6'
? `[${h.host}]:${h.port}`
: `${h.host}:${h.port}`
const arr = [...current.enabled]
if (enabled) {
if (!arr.includes(sa)) arr.push(sa)
} else {
const idx = arr.indexOf(sa)
if (idx >= 0) arr.splice(idx, 1)
}
current.enabled = arr
this.mockRevision([
{ op: PatchOp.REPLACE, path: `${basePath}/enabled`, value: arr },
])
} else {
const port = h.port ?? 0
const arr = current.disabled.filter(
([dHost, dPort]) => !(dHost === h.host && dPort === port),
)
if (!enabled) {
arr.push([h.host, port])
}
current.disabled = arr
this.mockRevision([
{ op: PatchOp.REPLACE, path: `${basePath}/disabled`, value: arr },
])
}
}
private mockData(path: string): any {
const parts = path.split('/').filter(Boolean)
let obj: any = mockPatchData
for (const part of parts) {
obj = obj[part]
}
return obj
}
private async mockRevision<T>(patch: Operation<T>[]): Promise<void> {
const revision = {
id: ++this.sequence,

View File

@@ -588,9 +588,13 @@ export const mockPatchData: DataModel = {
],
},
options: {
addSsl: null,
preferredExternalPort: 443,
secure: { ssl: true },
addSsl: {
preferredExternalPort: 443,
alpn: { specified: ['http/1.1', 'h2'] },
addXForwardedHeaders: false,
},
secure: null,
},
},
},

View File

@@ -1,11 +1,11 @@
export function toAuthorityName(
url: string | null,
addSsl = true,
): string | 'Local Root CA' | '-' {
): string | 'Root CA' | '-' {
if (url) {
return knownAuthorities.find(ca => ca.url === url)?.name || url
} else {
return addSsl ? 'Local Root CA' : '-'
return addSsl ? 'Root CA' : '-'
}
}