multiple bugs, better outbound gateway UX

This commit is contained in:
Matt Hill
2026-03-05 23:20:13 -07:00
parent 3901d38d65
commit 7693b0febc
18 changed files with 192 additions and 76 deletions

View File

@@ -16,7 +16,7 @@ export const VERSION = new InjectionToken<string>('VERSION')
host: {
target: '_blank',
rel: 'noreferrer',
'[href]': 'url()',
'[attr.href]': 'url()',
},
})
export class DocsLinkDirective {

View File

@@ -658,8 +658,6 @@ export default {
721: 'Gateway für ausgehenden Datenverkehr auswählen',
722: 'Der Typ des Gateways',
723: 'Nur ausgehend',
724: 'Als Standard für ausgehenden Verkehr festlegen',
725: 'Gesamten ausgehenden Datenverkehr über dieses Gateway leiten',
726: 'WireGuard-Konfigurationsdatei',
727: 'Eingehend/Ausgehend',
728: 'StartTunnel (Eingehend/Ausgehend)',
@@ -668,7 +666,6 @@ export default {
731: 'Öffentliche Domain',
732: 'Private Domain',
733: 'Ausblenden',
734: 'Standard ausgehend',
735: 'Zertifikat',
736: 'Selbstsigniert',
737: 'Portweiterleitung',
@@ -710,4 +707,7 @@ export default {
781: 'Lokal',
782: 'Unbekanntes Laufwerk',
783: 'Muss eine gültige E-Mail-Adresse sein',
786: 'Automatisch',
787: 'Ausgehender Datenverkehr',
788: 'Gateway verwenden',
} satisfies i18n

View File

@@ -658,8 +658,6 @@ export const ENGLISH: Record<string, number> = {
'Select the gateway for outbound traffic': 721,
'The type of gateway': 722,
'Outbound Only': 723,
'Set as default outbound': 724,
'Route all outbound traffic through this gateway': 725,
'WireGuard Config File': 726,
'Inbound/Outbound': 727,
'StartTunnel (Inbound/Outbound)': 728,
@@ -668,7 +666,6 @@ export const ENGLISH: Record<string, number> = {
'Public Domain': 731,
'Private Domain': 732,
'Hide': 733,
'default outbound': 734,
'Certificate': 735,
'Self signed': 736,
'Port Forwarding': 737,
@@ -710,4 +707,7 @@ export const ENGLISH: Record<string, number> = {
'Local': 781, // as in, locally accessible
'Unknown Drive': 782,
'Must be a valid email address': 783,
'Auto': 786,
'Outbound Traffic': 787,
'Use gateway': 788,
}

View File

@@ -658,8 +658,6 @@ export default {
721: 'Selecciona la puerta de enlace para el tráfico saliente',
722: 'El tipo de puerta de enlace',
723: 'Solo saliente',
724: 'Establecer como saliente predeterminado',
725: 'Enrutar todo el tráfico saliente a través de esta puerta de enlace',
726: 'Archivo de configuración WireGuard',
727: 'Entrante/Saliente',
728: 'StartTunnel (Entrante/Saliente)',
@@ -668,7 +666,6 @@ export default {
731: 'Dominio público',
732: 'Dominio privado',
733: 'Ocultar',
734: 'saliente predeterminado',
735: 'Certificado',
736: 'Autofirmado',
737: 'Reenvío de puertos',
@@ -710,4 +707,7 @@ export default {
781: 'Local',
782: 'Unidad desconocida',
783: 'Debe ser una dirección de correo electrónico válida',
786: 'Automático',
787: 'Tráfico saliente',
788: 'Usar gateway',
} satisfies i18n

View File

@@ -658,8 +658,6 @@ export default {
721: 'Sélectionnez la passerelle pour le trafic sortant',
722: 'Le type de passerelle',
723: 'Sortant uniquement',
724: 'Définir comme sortant par défaut',
725: 'Acheminer tout le trafic sortant via cette passerelle',
726: 'Fichier de configuration WireGuard',
727: 'Entrant/Sortant',
728: 'StartTunnel (Entrant/Sortant)',
@@ -668,7 +666,6 @@ export default {
731: 'Domaine public',
732: 'Domaine privé',
733: 'Masquer',
734: 'sortant par défaut',
735: 'Certificat',
736: 'Auto-signé',
737: 'Redirection de ports',
@@ -710,4 +707,7 @@ export default {
781: 'Local',
782: 'Lecteur inconnu',
783: 'Doit être une adresse e-mail valide',
786: 'Automatique',
787: 'Trafic sortant',
788: 'Utiliser la passerelle',
} satisfies i18n

View File

@@ -658,8 +658,6 @@ export default {
721: 'Wybierz bramę dla ruchu wychodzącego',
722: 'Typ bramy',
723: 'Tylko wychodzący',
724: 'Ustaw jako domyślne wychodzące',
725: 'Kieruj cały ruch wychodzący przez tę bramę',
726: 'Plik konfiguracyjny WireGuard',
727: 'Przychodzący/Wychodzący',
728: 'StartTunnel (Przychodzący/Wychodzący)',
@@ -668,7 +666,6 @@ export default {
731: 'Domena publiczna',
732: 'Domena prywatna',
733: 'Ukryj',
734: 'domyślne wychodzące',
735: 'Certyfikat',
736: 'Samopodpisany',
737: 'Przekierowanie portów',
@@ -710,4 +707,7 @@ export default {
781: 'Lokalny',
782: 'Nieznany dysk',
783: 'Musi być prawidłowy adres e-mail',
786: 'Automatycznie',
787: 'Ruch wychodzący',
788: 'Użyj bramy',
} satisfies i18n

View File

@@ -45,7 +45,7 @@ import { ABOUT } from './about.component'
}
<tui-data-list [style.width.rem]="13">
<tui-opt-group>
<button tuiOption iconStart="@tui.info" (click)="about()">
<button tuiOption iconStart="@tui.info" new (click)="about()">
{{ 'About this server' | i18n }}
</button>
</tui-opt-group>
@@ -53,13 +53,15 @@ import { ABOUT } from './about.component'
<a
tuiOption
docsLink
iconStart="@tui.book-open"
path="/start-os/user-manual/index.html"
new
iconStart="@tui.book-open-text"
path="/start-os/user-manual"
>
{{ 'User manual' | i18n }}
</a>
<a
tuiOption
new
iconStart="@tui.headphones"
href="https://start9.com/contact"
>
@@ -67,6 +69,7 @@ import { ABOUT } from './about.component'
</a>
<a
tuiOption
new
iconStart="@tui.dollar-sign"
href="https://donate.start9.com"
>
@@ -76,6 +79,7 @@ import { ABOUT } from './about.component'
<tui-opt-group label="">
<a
tuiOption
new
iconStart="@tui.settings"
routerLink="/system"
(click)="open = false"
@@ -86,6 +90,7 @@ import { ABOUT } from './about.component'
<tui-opt-group label="">
<button
tuiOption
new
iconStart="@tui.refresh-cw"
(click)="promptPower('restart')"
>
@@ -93,12 +98,13 @@ import { ABOUT } from './about.component'
</button>
<button
tuiOption
new
iconStart="@tui.power"
(click)="promptPower('shutdown')"
>
{{ 'Shutdown' | i18n }}
</button>
<button tuiOption iconStart="@tui.log-out" (click)="logout()">
<button tuiOption new iconStart="@tui.log-out" (click)="logout()">
{{ 'Logout' | i18n }}
</button>
</tui-opt-group>

View File

@@ -23,7 +23,7 @@ import { AuthoritiesTableComponent } from './table.component'
docsLink
path="/start-os/user-manual/trust-ca.html"
appearance="icon"
iconStart="@tui.external-link"
iconStart="@tui.book-open-text"
>
{{ 'Documentation' | i18n }}
</a>

View File

@@ -49,7 +49,7 @@ const ipv6 =
docsLink
path="/start-os/user-manual/dns.html"
appearance="icon"
iconStart="@tui.external-link"
iconStart="@tui.book-open-text"
>
{{ 'Documentation' | i18n }}
</a>

View File

@@ -57,7 +57,7 @@ function detectProviderKey(host: string | undefined): string {
docsLink
path="/start-os/user-manual/smtp.html"
appearance="icon"
iconStart="@tui.external-link"
iconStart="@tui.book-open-text"
>
{{ 'Documentation' | i18n }}
</a>

View File

@@ -1,5 +1,12 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
linkedSignal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import {
DocsLinkDirective,
@@ -7,14 +14,18 @@ import {
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
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 { ISB } from '@start9labs/start-sdk'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
import { TuiHeader } from '@taiga-ui/layout'
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 { GatewayService } from 'src/app/services/gateway.service'
import { TitleDirective } from 'src/app/services/title.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { GatewaysTableComponent } from './table.component'
@Component({
template: `
@@ -34,7 +45,7 @@ import { ISB } from '@start9labs/start-sdk'
docsLink
path="/start-os/user-manual/gateways.html"
appearance="icon"
iconStart="@tui.external-link"
iconStart="@tui.book-open-text"
>
{{ 'Documentation' | i18n }}
</a>
@@ -50,12 +61,99 @@ import { ISB } from '@start9labs/start-sdk'
</header>
<gateways-table />
</section>
@if (outboundOptions(); as options) {
<section class="outbound">
<header tuiHeader="body-l">
<h3 tuiTitle>
<b>
{{ 'Outbound Traffic' | i18n }}
<a
tuiIconButton
size="xs"
docsLink
path="/start-os/user-manual/gateways.html"
fragment="#outbound-traffic"
appearance="icon"
iconStart="@tui.book-open-text"
>
{{ 'Documentation' | i18n }}
</a>
</b>
</h3>
</header>
<tui-textfield
tuiChevron
[stringify]="stringifyOutbound"
[tuiTextfieldCleaner]="false"
>
<label tuiLabel>{{ 'Use gateway' | i18n }}</label>
@if (mobile) {
<select
tuiSelect
[ngModel]="selectedOutbound()"
(ngModelChange)="selectedOutbound.set($event)"
[items]="options"
></select>
} @else {
<input
tuiSelect
[ngModel]="selectedOutbound()"
(ngModelChange)="selectedOutbound.set($event)"
/>
}
@if (!mobile) {
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="options"
/>
}
</tui-textfield>
<footer>
<button
tuiButton
[disabled]="
selectedOutbound()?.id ===
(gatewayService.defaultOutbound() ?? null)
"
(click)="saveOutbound()"
>
{{ 'Save' | i18n }}
</button>
</footer>
</section>
}
`,
styles: `
.outbound {
max-width: 24rem;
margin-top: 2rem;
}
.outbound header {
margin-bottom: 1rem;
}
.outbound footer {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [GatewayService],
imports: [
CommonModule,
FormsModule,
RouterLink,
TuiButton,
TuiTextfield,
TuiTitle,
TuiChevron,
TuiSelect,
TuiDataListWrapper,
TuiHeader,
GatewaysTableComponent,
TitleDirective,
i18nPipe,
@@ -68,6 +166,50 @@ export default class GatewaysComponent {
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly i18n = inject(i18nPipe)
readonly gatewayService = inject(GatewayService)
readonly mobile = inject(TUI_IS_MOBILE)
private readonly autoOption = {
id: null,
name: this.i18n.transform('Auto') ?? 'Auto',
}
readonly outboundOptions = computed(() => {
const gateways = this.gatewayService.gateways()
if (!gateways) return null
return [
this.autoOption,
...gateways.map(g => ({ id: g.id as string | null, name: g.name })),
]
})
readonly selectedOutbound = linkedSignal(() => {
const options = this.outboundOptions()
const defaultId = this.gatewayService.defaultOutbound() ?? null
if (options) {
return options.find(o => o.id === defaultId) ?? options[0]
}
return this.autoOption
})
readonly stringifyOutbound = (opt: { id: string | null; name: string }) =>
opt.name
async saveOutbound() {
const loader = this.loader.open('Saving').subscribe()
console.log('outbound', this.selectedOutbound())
try {
await this.api.setDefaultOutbound({
gateway: this.selectedOutbound()?.id ?? null,
})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async add() {
const spec = ISB.InputSpec.of({
@@ -108,13 +250,6 @@ export default class GatewaysComponent {
},
}),
}),
setAsDefaultOutbound: ISB.Value.toggle({
name: this.i18n.transform('Set as default outbound'),
description: this.i18n.transform(
'Route all outbound traffic through this gateway',
),
default: false,
}),
})
this.formDialog.open(FormComponent, {
@@ -135,7 +270,7 @@ export default class GatewaysComponent {
? input.config.value.file
: await (input.config.value.file as any as File).text(),
type: null, // @TODO Aiden why is attr here?
setAsDefaultOutbound: input.setAsDefaultOutbound,
setAsDefaultOutbound: false,
})
return true
} catch (e: any) {

View File

@@ -23,9 +23,8 @@ import { filter } from 'rxjs'
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'
import { GatewayPlus } from 'src/app/services/gateway.service'
import { TuiBadge } from '@taiga-ui/kit'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { PORT_FORWARDS_MODAL } from './port-forwards.component'
@Component({
@@ -45,11 +44,6 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
}
}
{{ gateway.name }}
@if (gateway.isDefaultOutbound) {
<tui-badge appearance="primary-success">
{{ 'default outbound' | i18n }}
</tui-badge>
}
</td>
<td>
@if (gateway.type === 'outbound-only') {
@@ -91,13 +85,6 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
</button>
</tui-opt-group>
}
@if (!gateway.isDefaultOutbound) {
<tui-opt-group>
<button tuiOption new (click)="setDefaultOutbound()">
{{ 'Set as default outbound' | i18n }}
</button>
</tui-opt-group>
}
@if (gateway.ipInfo.deviceType === 'wireguard') {
<tui-opt-group>
<button tuiOption new class="g-negative" (click)="remove()">
@@ -116,8 +103,8 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
margin-right: 0.7rem;
}
tui-badge {
margin-left: 1rem;
td:first-child {
width: 24rem;
}
td:last-child {
@@ -171,7 +158,6 @@ import { PORT_FORWARDS_MODAL } from './port-forwards.component'
TuiOptGroup,
TuiTextfield,
i18nPipe,
TuiBadge,
],
})
export class GatewaysItemComponent {
@@ -214,18 +200,6 @@ export class GatewaysItemComponent {
})
}
async setDefaultOutbound() {
const loader = this.loader.open().subscribe()
try {
await this.api.setDefaultOutbound({ gateway: this.gateway().id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async rename() {
const { id, name } = this.gateway()
const renameSpec = ISB.InputSpec.of({

View File

@@ -21,7 +21,6 @@ import { GatewayService } from 'src/app/services/gateway.service'
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [GatewayService],
imports: [TuiSkeleton, i18nPipe, TableComponent, GatewaysItemComponent],
})
export class GatewaysTableComponent {

View File

@@ -41,7 +41,7 @@ import { SSHTableComponent } from './table.component'
docsLink
path="/start-os/user-manual/ssh.html"
appearance="icon"
iconStart="@tui.external-link"
iconStart="@tui.book-open-text"
>
{{ 'Documentation' | i18n }}
</a>

View File

@@ -56,7 +56,7 @@ import { wifiSpec } from './wifi.const'
docsLink
path="/start-os/user-manual/wifi.html"
appearance="icon"
iconStart="@tui.external-link"
iconStart="@tui.book-open-text"
>
{{ 'Documentation' | i18n }}
</a>

View File

@@ -12,7 +12,6 @@ export type GatewayPlus = T.NetworkInterfaceInfo & {
subnets: utils.IpNet[]
lanIpv4: string[]
wanIp?: utils.IpAddress
isDefaultOutbound: boolean
}
@Injectable()
@@ -29,7 +28,6 @@ export class GatewayService {
this.network$.pipe(
map(network => {
const gateways = network.gateways
const defaultOutbound = network.defaultOutbound
return Object.entries(gateways)
.filter(([_, val]) => !!val?.ipInfo)
.filter(
@@ -49,7 +47,6 @@ export class GatewayService {
lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address),
wanIp:
val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp),
isDefaultOutbound: id === defaultOutbound,
} as GatewayPlus
})
}),

View File

@@ -161,7 +161,6 @@ export class MarketplaceService {
}
private fetchRegistry$(url: string): Observable<StoreDataWithUrl | null> {
console.log('FETCHING REGISTRY: ', url)
return combineLatest([this.fetchInfo$(url), this.fetchPackages$(url)]).pipe(
map(([info, packages]) => ({ info, packages, url })),
catchError(e => {

View File

@@ -70,6 +70,12 @@ hr {
min-height: fit-content;
flex: 1;
padding: 1rem;
&::after {
content: '';
display: block;
height: 1rem;
}
}
.g-aside {