mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
MVP of service interface page
This commit is contained in:
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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]',
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
36
web/projects/ui/src/app/services/gateway.service.ts
Normal file
36
web/projects/ui/src/app/services/gateway.service.ts
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user