MVP of service interface page

This commit is contained in:
Matt Hill
2025-08-08 20:57:16 -06:00
parent 4f24658d33
commit 35ace3997b
18 changed files with 396 additions and 190 deletions

View File

@@ -4,31 +4,19 @@ import { TuiButton } from '@taiga-ui/core'
import { TuiAccordion } from '@taiga-ui/experimental'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { MappedServiceInterface } from '../interface.utils'
import { AddressActionsComponent } from './actions.component'
import { MappedServiceInterface } from '../interface.service'
import { InterfaceAddressItemComponent } from './item.component'
@Component({
selector: 'section[addresses]',
template: `
<header>{{ 'Addresses' | i18n }}</header>
<table [appTable]="[null, 'Type', 'Gateway', 'URL', null]">
@for (address of addresses().common; track $index) {
<tr>
<td>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.eye"
(click)="instructions()"
>
{{ 'View instructions' | i18n }}
</button>
</td>
<td>{{ address.type }}</td>
<td [style.order]="-1">{{ address.gateway }}</td>
<td>{{ address.url }}</td>
<td actions [disabled]="!isRunning()" [href]="address.url"></td>
</tr>
@for (
address of addresses().common.concat(addresses().uncommon);
track $index
) {
<tr [address]="address" [isRunning]="isRunning()"></tr>
} @empty {
<tr>
<td colspan="5">
@@ -38,35 +26,17 @@ import { AddressActionsComponent } from './actions.component'
</td>
</tr>
}
</table>
@if (addresses().uncommon.length) {
<tui-accordion>
<button tuiAccordion>{{ 'Uncommon' | i18n }}</button>
<tui-expand>
<table [appTable]="[null, 'Type', 'Gateway', 'URL', null]">
<!-- @if (addresses().uncommon.length) {
<tui-accordion>
<button tuiAccordion>{{ 'Uncommon' | i18n }}</button>
<tui-expand>
@for (address of addresses().uncommon; track $index) {
<tr>
<td>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.eye"
(click)="instructions()"
>
{{ 'View instructions' | i18n }}
</button>
</td>
<td>{{ address.type }}</td>
<td [style.order]="-1">{{ address.gateway }}</td>
<td>{{ address.url }}</td>
<td actions [disabled]="!isRunning()" [href]="address.url"></td>
</tr>
<tr [address]="address" [isRunning]="isRunning()"></tr>
}
</table>
</tui-expand>
</tui-accordion>
}
</tui-expand>
</tui-accordion>
} -->
</table>
`,
styles: `
[tuiAccordion],
@@ -79,25 +49,13 @@ import { AddressActionsComponent } from './actions.component'
margin-inline-end: 0.25rem;
}
}
:host-context(tui-root._mobile) {
td:first-child {
display: none;
}
td:nth-child(2) {
font: var(--tui-font-text-m);
font-weight: bold;
color: var(--tui-text-primary);
}
}
`,
host: { class: 'g-card' },
imports: [
TableComponent,
PlaceholderComponent,
i18nPipe,
AddressActionsComponent,
InterfaceAddressItemComponent,
TuiButton,
TuiAccordion,
],
@@ -106,6 +64,4 @@ import { AddressActionsComponent } from './actions.component'
export class InterfaceAddressesComponent {
readonly addresses = input.required<MappedServiceInterface['addresses']>()
readonly isRunning = input.required<boolean>()
instructions() {}
}

View File

@@ -0,0 +1,63 @@
import {
ChangeDetectionStrategy,
Component,
input,
inject,
} from '@angular/core'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { DisplayAddress } from '../interface.service'
import { AddressActionsComponent } from './actions.component'
@Component({
selector: 'tr[address]',
template: `
@if (address(); as address) {
<td>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.eye"
(click)="instructions(address.bullets)"
>
{{ 'View instructions' | i18n }}
</button>
</td>
<td>{{ address.type }}</td>
<td [style.order]="-1">{{ address.gatewayName || '-' }}</td>
<td>{{ address.url }}</td>
<td actions [disabled]="!isRunning()" [href]="address.url"></td>
}
`,
styles: `
:host-context(tui-root._mobile) {
td:first-child {
display: none;
}
td:nth-child(2) {
font: var(--tui-font-text-m);
font-weight: bold;
color: var(--tui-text-primary);
}
}
`,
imports: [i18nPipe, AddressActionsComponent, TuiButton],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressItemComponent {
readonly address = input.required<DisplayAddress>()
readonly isRunning = input.required<boolean>()
readonly dialog = inject(DialogService)
instructions(bullets: string[]) {
this.dialog
.openAlert(
`<ul>${bullets.map(b => `<li>${b}</li>`).join('')}</ul>` as i18nKey,
{
label: 'About this address' as i18nKey,
},
)
.subscribe()
}
}

View File

@@ -5,7 +5,7 @@ import { PlaceholderComponent } from 'src/app/routes/portal/components/placehold
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { DomainComponent } from './domain.component'
import { ClearnetDomain } from './interface.utils'
import { ClearnetDomain } from './interface.service'
@Component({
selector: 'section[clearnetDomains]',

View File

@@ -20,7 +20,7 @@ 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.utils'
import { ClearnetDomain } from './interface.service'
@Component({
selector: 'tr[domain]',

View File

@@ -1,16 +1,11 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core'
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { TuiTitle } from '@taiga-ui/core'
import { TuiSwitch } from '@taiga-ui/kit'
import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
import { TuiCell } from '@taiga-ui/layout'
import { InterfaceGateway } from './interface.utils'
import { InterfaceGateway } from './interface.service'
@Component({
selector: 'section[gateways]',
@@ -18,7 +13,7 @@ import { InterfaceGateway } from './interface.utils'
<header>{{ 'Gateways' | i18n }}</header>
@for (gateway of gateways(); track $index) {
<label tuiCell="s">
<span tuiTitle>{{ gateway.name }}</span>
<span tuiTitle>{{ gateway.ipInfo.name }}</span>
<input
type="checkbox"
tuiSwitch

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { tuiButtonOptionsProvider } from '@taiga-ui/core'
import { MappedServiceInterface } from './interface.utils'
import { MappedServiceInterface } from './interface.service'
import { InterfaceGatewaysComponent } from './gateways.component'
import { InterfaceTorDomainsComponent } from './tor-domains.component'
import { InterfaceClearnetDomainsComponent } from './clearnet-domains.component'

View File

@@ -1,9 +1,12 @@
import { inject, Injectable } from '@angular/core'
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 { i18nKey } from '@start9labs/shared'
type AddressWithInfo = {
address: URL
url: URL
info: T.HostnameInfo
}
@@ -14,7 +17,7 @@ function cmpWithRankedPredicates<T extends AddressWithInfo>(
): -1 | 0 | 1 {
for (const pred of preds) {
for (let [x, y, sign] of [[a, b, 1] as const, [b, a, -1] as const]) {
if (pred(x) && !pred(y)) return sign
if (pred(y) && !pred(x)) return sign
}
}
return 0
@@ -26,8 +29,7 @@ function filterTor(a: AddressWithInfo): a is TorAddress {
}
function cmpTor(a: TorAddress, b: TorAddress): -1 | 0 | 1 {
for (let [x, y, sign] of [[a, b, 1] as const, [b, a, -1] as const]) {
if (x.address.protocol === 'http:' && y.address.protocol === 'https:')
return sign
if (y.url.protocol === 'http:' && x.url.protocol === 'https:') return sign
}
return 0
}
@@ -98,8 +100,161 @@ function cmpClearnet(
])
}
function toDisplayAddress(a: AddressWithInfo): Address {
throw new Error('@TODO: MattHill')
// @TODO translations
function toDisplayAddress(
{ info, url }: AddressWithInfo,
gateways: GatewayPlus[],
domains: Record<string, T.DomainConfig>,
): DisplayAddress {
let access: DisplayAddress['access']
let gatewayName: DisplayAddress['gatewayName']
let type: DisplayAddress['type']
let bullets: any[]
// let bullets: DisplayAddress['bullets']
const rootCaRequired = `Requires trusting your server's Root CA`
// ** Tor **
if (info.kind === 'onion') {
access = null
gatewayName = null
type = 'Tor'
bullets = [
'Connections can be slow or unreliable at times',
'Public if you share the address publicly, otherwise private',
'Requires using a Tor-enabled device or browser',
]
// Tor (HTTPS)
if (url.protocol.startsWith('https')) {
type = `${type} (HTTPS)`
bullets = [
'Only useful for clients that enforce HTTPS',
rootCaRequired,
...bullets,
]
// Tor (HTTP)
} else {
bullets.unshift('Ideal for anonymous, remote connectivity')
type = `${type} (HTTP)`
}
// ** Not Tor **
} else {
const port = info.hostname.sslPort || info.hostname.port
const gateway = gateways.find(g => g.id === info.gatewayId)!
gatewayName = gateway.ipInfo.name
const gatewayIpv4 = gateway.ipInfo.subnets[0]
const isWireguard = gateway.ipInfo.deviceType === 'wireguard'
const localIdeal = 'Ideal for local access'
const lanRequired =
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN'
const staticRequired = `Requires setting a static IP address for ${gatewayIpv4} in your gateway`
const vpnAccess = 'Ideal for VPN access via your'
// * Local *
if (info.hostname.kind === 'local') {
type = 'Local'
access = 'private'
bullets = [
localIdeal,
'Not recommended for VPN access. VPNs do not support ".local" domains without extra configuration',
lanRequired,
rootCaRequired,
]
// * IPv4 *
} else if (info.hostname.kind === 'ipv4') {
type = 'IPv4'
if (info.public) {
access = 'public'
bullets = [
'Can be used for clearnet access',
'Not recommended in most cases',
rootCaRequired,
]
if (!gateway.public) {
bullets.push(
`Requires creating a port forwarding rule in gateway "${gatewayName}": ${port} -> ${info.hostname.value}:${port}`,
)
}
} else {
access = 'private'
if (isWireguard) {
bullets = [`${vpnAccess} StartTunnel (or similar)`, rootCaRequired]
} else {
bullets = [
localIdeal,
`${vpnAccess} router's Wireguard server`,
lanRequired,
rootCaRequired,
staticRequired,
]
}
}
// * IPv6 *
} else if (info.hostname.kind === 'ipv6') {
type = 'IPv6'
access = 'private'
bullets = ['Can be used for local access', lanRequired, rootCaRequired]
// * Domain *
} else {
type = 'Domain'
const domain = domains[info.hostname.value]!
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}`,
]
if (domain.acme) {
bullets.unshift('Ideal for public access via the Internet')
} else {
bullets = [
'Can be used for personal access via the public Internet. VPN is more secure',
rootCaRequired,
...bullets,
]
}
} 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}`
if (isWireguard) {
bullets = [
`${vpnAccess} StartTunnel (or similar) ${ipPortBad}`,
customDnsRequired,
]
} else {
bullets = [
`${localIdeal} ${ipPortBad}`,
`${vpnAccess} router's Wireguard server ${ipPortBad}`,
customDnsRequired,
lanRequired,
staticRequired,
]
}
if (domain.acme) {
bullets.push(rootCaRequired)
}
}
}
}
return {
url: url.href,
access,
gatewayName,
type,
bullets,
}
}
export function getClearnetDomains(host: T.Host): ClearnetDomain[] {
return Object.entries(host.domains).map(([fqdn, info]) => ({
fqdn,
authority: toAuthorityName(info.acme),
public: info.public,
}))
}
@Injectable({
@@ -109,9 +264,10 @@ export class InterfaceService {
private readonly config = inject(ConfigService)
getAddresses(
serverDomains: Record<string, T.DomainSettings>,
serviceInterface: T.ServiceInterface,
host: T.Host,
serverDomains: Record<string, T.DomainSettings>,
gateways: GatewayPlus[],
): MappedServiceInterface['addresses'] {
const hostnamesInfos = this.hostnameInfo(serviceInterface, host)
@@ -125,7 +281,7 @@ export class InterfaceService {
const allAddressesWithInfo: AddressWithInfo[] = hostnamesInfos.flatMap(h =>
utils
.addressHostToUrl(serviceInterface.addressInfo, h)
.map(a => ({ address: new URL(a), info: h })),
.map(a => ({ url: new URL(a), info: h })),
)
const torAddrs = allAddressesWithInfo.filter(filterTor).sort(cmpTor)
@@ -147,10 +303,10 @@ export class InterfaceService {
}, [] as AddressWithInfo[])
return {
common: bestAddrs.map(toDisplayAddress),
common: bestAddrs.map(a => toDisplayAddress(a, gateways, host.domains)),
uncommon: allAddressesWithInfo
.filter(a => !bestAddrs.includes(a))
.map(toDisplayAddress),
.map(a => toDisplayAddress(a, gateways, host.domains)),
}
}
@@ -297,28 +453,35 @@ export type MappedServiceInterface = T.ServiceInterface & {
torDomains: string[]
clearnetDomains: ClearnetDomain[]
addresses: {
common: Address[]
uncommon: Address[]
common: DisplayAddress[]
uncommon: DisplayAddress[]
}
isOs: boolean
}
export type InterfaceGateway = {
id: string
name: string
export type InterfaceGateway = GatewayPlus & {
enabled: boolean
public: 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
public: boolean
}
export type Address = {
export type DisplayAddress = {
type: string
gateway: string
access: 'public' | 'private' | null
gatewayName: string | null
url: string
description: string
bullets: i18nKey[]
}

View File

@@ -11,6 +11,7 @@ import { TuiButton } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { InterfaceService } from '../../../components/interfaces/interface.service'
@Component({
selector: 'tr[serviceInterface]',
@@ -82,7 +83,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
imports: [TuiButton, TuiBadge, RouterLink],
})
export class ServiceInterfaceItemComponent {
private readonly config = inject(ConfigService)
private readonly interfaceService = inject(InterfaceService)
private readonly document = inject(DOCUMENT)
@Input({ required: true })
@@ -110,7 +111,7 @@ export class ServiceInterfaceItemComponent {
get href() {
const host = this.pkg.hosts[this.info.addressInfo.hostId]
if (!host) return ''
return this.config.launchableAddress(this.info, host)
return this.interfaceService.launchableAddress(this.info, host)
}
openUI() {

View File

@@ -9,8 +9,8 @@ import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { tuiPure } from '@taiga-ui/cdk'
import { TuiDataList, TuiDropdown, TuiButton } from '@taiga-ui/core'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { InterfaceService } from '../../../components/interfaces/interface.service'
@Component({
selector: 'app-ui-launch',
@@ -59,7 +59,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
imports: [TuiButton, TuiDropdown, TuiDataList, i18nPipe],
})
export class UILaunchComponent {
private readonly config = inject(ConfigService)
private readonly interfaceService = inject(InterfaceService)
private readonly document = inject(DOCUMENT)
@Input()
@@ -88,7 +88,7 @@ export class UILaunchComponent {
getHref(ui: T.ServiceInterface): string {
const host = this.pkg.hosts[ui.addressInfo.hostId]
if (!host) return ''
return this.config.launchableAddress(ui, host)
return this.interfaceService.launchableAddress(ui, host)
}
openUI(ui: T.ServiceInterface) {

View File

@@ -15,9 +15,13 @@ import { TuiBadge, TuiBreadcrumbs } from '@taiga-ui/kit'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import {
getClearnetDomains,
InterfaceService,
} from '../../../components/interfaces/interface.service'
import { GatewayService } from 'src/app/services/gateway.service'
@Component({
template: `
@@ -25,15 +29,15 @@ import { TitleDirective } from 'src/app/services/title.service'
<a routerLink="../.." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }}
</a>
{{ interface()?.name }}
{{ serviceInterface()?.name }}
</ng-container>
<tui-breadcrumbs size="l">
<a *tuiItem tuiLink appearance="action-grayscale" routerLink="../..">
{{ 'Dashboard' | i18n }}
</a>
<span *tuiItem class="g-primary">{{ interface()?.name }}</span>
<span *tuiItem class="g-primary">{{ serviceInterface()?.name }}</span>
</tui-breadcrumbs>
@if (interface(); as value) {
@if (serviceInterface(); as value) {
<header tuiHeader [style.margin-bottom.rem]="1">
<hgroup tuiTitle>
<h3>
@@ -71,6 +75,7 @@ import { TitleDirective } from 'src/app/services/title.service'
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [GatewayService],
imports: [
InterfaceComponent,
RouterLink,
@@ -86,43 +91,59 @@ import { TitleDirective } from 'src/app/services/title.service'
],
})
export default class ServiceInterfaceRoute {
private readonly config = inject(ConfigService)
private readonly interfaceService = inject(InterfaceService)
private readonly gatewayService = inject(GatewayService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly pkgId = getPkgId()
readonly interfaceId = input('')
readonly pkg = toSignal(
inject<PatchDB<DataModel>>(PatchDB).watch$('packageData', this.pkgId),
readonly pkg = toSignal(this.patch.watch$('packageData', this.pkgId))
readonly domains = toSignal(
this.patch.watch$('serverInfo', 'network', 'domains'),
)
readonly isRunning = computed(() => {
return this.pkg()?.status.main === 'running'
})
readonly interface = computed(() => {
readonly serviceInterface = computed(() => {
const pkg = this.pkg()
const id = this.interfaceId()
const domains = this.domains()
if (!pkg || !id) {
if (!pkg || !id || !domains) {
return
}
const { serviceInterfaces, hosts } = pkg
const item = serviceInterfaces[this.interfaceId()]
const key = item?.addressInfo.hostId || ''
const iFace = serviceInterfaces[this.interfaceId()]
const key = iFace?.addressInfo.hostId || ''
const host = hosts[key]
const port = item?.addressInfo.internalPort
const port = iFace?.addressInfo.internalPort
if (!host || !item || !port) {
if (!host || !iFace || !port) {
return
}
const gateways = this.gatewayService.gateways() || []
return {
...item,
addresses: this.config.getAddresses(item, host),
gateways: [],
torDomains: [],
clearnetDomains: [],
...iFace,
addresses: this.interfaceService.getAddresses(
iFace,
host,
domains,
gateways,
),
gateways:
gateways.map(g => ({
enabled: true,
...g,
})) || [],
torDomains: host.onions.map(o => `${o}.onion`),
clearnetDomains: getClearnetDomains(host),
isOs: false,
}
})

View File

@@ -42,7 +42,7 @@ export class DomainService {
readonly data = toSignal(
this.patch.watch$('serverInfo', 'network').pipe(
map(({ gateways, domains, acme }) => ({
map(({ gateways, domains }) => ({
gateways: Object.entries(gateways).reduce<Record<string, string>>(
(obj, [id, n]) => ({
...obj,
@@ -51,7 +51,7 @@ export class DomainService {
{},
),
domains: Object.entries(domains).map(
([fqdn, { gateway, acme }]) =>
([fqdn, { gateway }]) =>
({
fqdn,
subdomain: parse(fqdn).subdomain,

View File

@@ -8,17 +8,13 @@ import {
LoadingService,
} from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GatewaysTableComponent } from './table.component'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { TitleDirective } from 'src/app/services/title.service'
import { map } from 'rxjs'
import { ISB } from '@start9labs/start-sdk'
import { GatewayPlus } from './item.component'
@Component({
template: `
@@ -52,7 +48,7 @@ import { GatewayPlus } from './item.component'
Add
</button>
</header>
<div [gateways]="gateways$ | async"></div>
<gateways-table />
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -72,25 +68,6 @@ export default class GatewaysComponent {
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
readonly gateways$ = inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'gateways')
.pipe(
map(gateways =>
Object.entries(gateways)
.filter(([_, val]) => !!val.ipInfo)
.map(
([id, val]) =>
({
...val,
id,
ipv4: val.ipInfo?.subnets
.filter(s => !s.includes('::'))
.map(s => s.split('/')[0]),
}) as GatewayPlus,
),
),
)
async add() {
this.formDialog.open(FormComponent, {
label: 'Add gateway',

View File

@@ -10,7 +10,7 @@ import {
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, T } from '@start9labs/start-sdk'
import { ISB } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDataList,
@@ -23,12 +23,7 @@ import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
export type GatewayPlus = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo
ipv4: string[]
}
import { GatewayPlus } from 'src/app/services/gateway.service'
@Component({
selector: 'tr[gateway]',

View File

@@ -1,11 +1,12 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiSkeleton } from '@taiga-ui/kit'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { GatewaysItemComponent, GatewayPlus } from './item.component'
import { GatewaysItemComponent } from './item.component'
import { GatewayService } from 'src/app/services/gateway.service'
@Component({
selector: '[gateways]',
selector: 'gateways-table',
template: `
<table
[appTable]="[
@@ -17,7 +18,7 @@ import { GatewaysItemComponent, GatewayPlus } from './item.component'
null,
]"
>
@for (gateway of gateways(); track $index) {
@for (gateway of gatewayService.gateways(); track $index) {
<tr [gateway]="gateway"></tr>
} @empty {
<tr>
@@ -29,8 +30,9 @@ import { GatewaysItemComponent, GatewayPlus } from './item.component'
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [GatewayService],
imports: [TuiSkeleton, i18nPipe, TableComponent, GatewaysItemComponent],
})
export class GatewaysTableComponent<T extends GatewayPlus> {
readonly gateways = input<readonly T[] | null>(null)
export class GatewaysTableComponent {
protected readonly gatewayService = inject(GatewayService)
}

View File

@@ -13,8 +13,11 @@ import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { ConfigService } from 'src/app/services/config.service'
import {
getClearnetDomains,
InterfaceService,
} from 'src/app/routes/portal/components/interfaces/interface.service'
import { GatewayService } from 'src/app/services/gateway.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
@@ -40,6 +43,7 @@ import { TitleDirective } from 'src/app/services/title.service'
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [GatewayService],
imports: [
InterfaceComponent,
RouterLink,
@@ -51,7 +55,8 @@ import { TitleDirective } from 'src/app/services/title.service'
],
})
export default class StartOsUiComponent {
private readonly config = inject(ConfigService)
private readonly interfaceService = inject(InterfaceService)
private readonly gatewayService = inject(GatewayService)
private readonly i18n = inject(i18nPipe)
readonly iface: T.ServiceInterface = {
@@ -72,27 +77,31 @@ export default class StartOsUiComponent {
},
}
readonly ui = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'host')
.pipe(
map(host => {
return {
...this.iface,
addresses: getAddresses(this.iface, host, this.config),
gateways: [
{
id: 'eth0',
name: 'Wired Connection 1',
public: false,
enabled: true,
},
],
torDomains: [],
clearnetDomains: [],
isOs: true,
}
}),
),
readonly network = toSignal(
inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo', 'network'),
)
readonly ui = computed(() => {
const network = this.network()
const gateways = this.gatewayService.gateways()
if (!network || !gateways) return
return {
...this.iface,
addresses: this.interfaceService.getAddresses(
this.iface,
network.host,
network.domains,
gateways,
),
gateways: gateways.map(g => ({
enabled: true,
...g,
})),
torDomains: network.host.onions.map(o => `${o}.onion`),
clearnetDomains: getClearnetDomains(network.host),
isOs: true,
}
})
}

View File

@@ -35,11 +35,9 @@ export const mockPatchData: DataModel = {
domains: {
'cloud.private.com': {
gateway: 'eth0',
acme: null,
},
'public.com': {
gateway: 'wireguard1',
acme: 'https://acme-v02.api.letsencrypt.org/directory',
},
},
host: {

View File

@@ -0,0 +1,36 @@
import { inject, Injectable } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { T } from '@start9labs/start-sdk'
import { map } from 'rxjs/operators'
import { DataModel } from './patch-db/data-model'
import { toSignal } from '@angular/core/rxjs-interop'
export type GatewayPlus = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo
ipv4: string[]
}
@Injectable()
export class GatewayService {
readonly gateways = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'gateways')
.pipe(
map(gateways =>
Object.entries(gateways)
.filter(([_, val]) => !!val.ipInfo)
.map(
([id, val]) =>
({
...val,
id,
ipv4: val.ipInfo?.subnets
.filter(s => !s.includes('::'))
.map(s => s.split('/')[0]),
}) as GatewayPlus,
),
),
),
)
}

View File

@@ -4,16 +4,6 @@ import { T } from '@start9labs/start-sdk'
export type DataModel = T.Public & {
ui: UIData
packageData: AllPackageData
serverInfo: T.ServerInfo & {
network: T.NetworkInfo & {
domains: {
[fqdn: string]: {
gateway: string
acme: string | null
}
}
}
}
}
export type UIData = {