outbound gateway support (#3120)

* Multiple (#3111)

* fix alerts i18n, fix status display, better, remove usb media, hide shutdown for install complete

* trigger chnage detection for localize pipe and round out implementing localize pipe for consistency even though not needed

* Fix PackageInfoShort to handle LocaleString on releaseNotes (#3112)

* Fix PackageInfoShort to handle LocaleString on releaseNotes

* fix: filter by target_version in get_matching_models and pass otherVersions from install

* chore: add exver documentation for ai agents

* frontend plus some be types

---------

Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>
This commit is contained in:
Matt Hill
2026-02-12 08:27:09 -07:00
committed by GitHub
parent 2a54625f43
commit 8ef4ecf5ac
37 changed files with 1113 additions and 239 deletions

View File

@@ -15,7 +15,6 @@ import { TuiBadge } from '@taiga-ui/kit'
`,
styles: `
:host {
clip-path: inset(0 round 0.75rem);
cursor: pointer;
&:hover {

View File

@@ -6,12 +6,18 @@ import {
inject,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { getPkgId, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
ErrorService,
getPkgId,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, T } from '@start9labs/start-sdk'
import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { firstValueFrom, map } from 'rxjs'
import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { getManifest } from 'src/app/utils/get-package-data'
@@ -20,6 +26,9 @@ import {
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
const INACTIVE: PrimaryStatus[] = [
'installing',
@@ -65,6 +74,12 @@ const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
<section class="g-card">
<header>StartOS</header>
<button
tuiCell
[action]="outboundGatewayAction()"
[inactive]="inactive"
(click)="openOutboundGatewayModal()"
></button>
<button
tuiCell
[action]="rebuild"
@@ -95,66 +110,78 @@ const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
export default class ServiceActionsRoute {
private readonly actions = inject(ActionService)
private readonly i18n = inject(i18nPipe)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly formDialog = inject(FormDialogService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
ungrouped: 'General' | 'Other' = 'General'
readonly service = inject(StandardActionsService)
readonly package = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData', getPkgId())
.pipe(
map(pkg => {
const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
? 'Other'
: 'General'
const status = renderPkgStatus(pkg).primary
return {
status,
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.entries(pkg.actions)
.filter(([_, action]) => action.visibility !== 'hidden')
.map(([id, action]) => ({
...action,
id,
group: action.group || specialGroup,
visibility: ALLOWED_STATUSES[action.allowedStatuses].has(
status,
)
? action.visibility
: ({
disabled: `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`,
} as T.ActionVisibility),
}))
.sort((a, b) => {
if (a.group === specialGroup && b.group !== specialGroup)
return 1
if (b.group === specialGroup && a.group !== specialGroup)
return -1
this.patch.watch$('packageData', getPkgId()).pipe(
map(pkg => {
const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
? 'Other'
: 'General'
const status = renderPkgStatus(pkg).primary
return {
status,
icon: pkg.icon,
manifest: getManifest(pkg),
outboundGateway: pkg.outboundGateway,
actions: Object.entries(pkg.actions)
.filter(([_, action]) => action.visibility !== 'hidden')
.map(([id, action]) => ({
...action,
id,
group: action.group || specialGroup,
visibility: ALLOWED_STATUSES[action.allowedStatuses].has(status)
? action.visibility
: ({
disabled: `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`,
} as T.ActionVisibility),
}))
.sort((a, b) => {
if (a.group === specialGroup && b.group !== specialGroup) return 1
if (b.group === specialGroup && a.group !== specialGroup)
return -1
const groupCompare = a.group.localeCompare(b.group) // sort groups lexicographically
if (groupCompare !== 0) return groupCompare
const groupCompare = a.group.localeCompare(b.group) // sort groups lexicographically
if (groupCompare !== 0) return groupCompare
return a.id.localeCompare(b.id) // sort actions within groups lexicographically
})
.reduce<
Record<
string,
Array<T.ActionMetadata & { id: string; group: string }>
>
>((acc, action) => {
const key = action.group
if (!acc[key]) {
acc[key] = []
}
acc[key].push(action)
return acc
}, {}),
}
}),
),
return a.id.localeCompare(b.id) // sort actions within groups lexicographically
})
.reduce<
Record<
string,
Array<T.ActionMetadata & { id: string; group: string }>
>
>((acc, action) => {
const key = action.group
if (!acc[key]) {
acc[key] = []
}
acc[key].push(action)
return acc
}, {}),
}
}),
),
)
readonly outboundGatewayAction = computed(() => {
const pkg = this.package()
const gateway = pkg?.outboundGateway
return {
name: this.i18n.transform('Set Outbound Gateway')!,
description: gateway
? `${this.i18n.transform('Current')}: ${gateway}`
: `${this.i18n.transform('Current')}: ${this.i18n.transform('System')}`,
}
})
readonly rebuild = {
name: this.i18n.transform('Rebuild Service')!,
description: this.i18n.transform(
@@ -181,6 +208,71 @@ export default class ServiceActionsRoute {
})
}
async openOutboundGatewayModal() {
const pkg = this.package()
if (!pkg) return
const gateways = await firstValueFrom(
this.patch.watch$('serverInfo', 'network', 'gateways'),
)
const SYSTEM_KEY = 'system'
const options: Record<string, string> = {
[SYSTEM_KEY]: this.i18n.transform('System default')!,
}
Object.entries(gateways)
.filter(
([_, g]) =>
!!g.ipInfo &&
g.ipInfo.deviceType !== 'bridge' &&
g.ipInfo.deviceType !== 'loopback',
)
.forEach(([id, g]) => {
options[id] = g.name ?? g.ipInfo?.name ?? id
})
const spec = ISB.InputSpec.of({
gateway: ISB.Value.select({
name: this.i18n.transform('Outbound Gateway'),
description: this.i18n.transform(
'Select the gateway for outbound traffic',
),
default: pkg.outboundGateway ?? SYSTEM_KEY,
values: options,
}),
})
this.formDialog.open(FormComponent, {
label: 'Set Outbound Gateway',
data: {
spec: await configBuilderToSpec(spec),
buttons: [
{
text: this.i18n.transform('Save'),
handler: async (input: typeof spec._TYPE) => {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.setServiceOutbound({
packageId: pkg.manifest.id,
gateway: input.gateway === SYSTEM_KEY ? null : input.gateway,
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
},
},
],
},
})
}
protected readonly isInactive = computed(
(pkg = this.package()) => !pkg || INACTIVE.includes(pkg.status),
)

View File

@@ -15,6 +15,7 @@ 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 { RR } from 'src/app/services/api/api.types'
@Component({
template: `
@@ -51,11 +52,6 @@ import { ISB } from '@start9labs/start-sdk'
<gateways-table />
</section>
`,
styles: `
:host {
max-width: 64rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
@@ -85,8 +81,19 @@ export default class GatewaysComponent {
default: null,
placeholder: 'StartTunnel 1',
}),
type: ISB.Value.select({
name: this.i18n.transform('Type'),
description: this.i18n.transform('The type of gateway'),
default: 'inbound-outbound',
values: {
'inbound-outbound': this.i18n.transform(
'StartTunnel (Inbound/Outbound)',
),
'outbound-only': this.i18n.transform('Outbound Only'),
},
}),
config: ISB.Value.union({
name: this.i18n.transform('StartTunnel Config File'),
name: this.i18n.transform('Wireguard Config File'),
default: 'paste',
variants: ISB.Variants.of({
paste: {
@@ -113,10 +120,17 @@ 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, {
label: 'Add StartTunnel Gateway',
label: 'Add Wireguard Gateway',
data: {
spec: await configBuilderToSpec(spec),
buttons: [
@@ -132,7 +146,8 @@ export default class GatewaysComponent {
input.config.selection === 'paste'
? input.config.value.file
: await (input.config.value.file as any as File).text(),
public: false,
type: input.type as RR.GatewayType,
setAsDefaultOutbound: input.setAsDefaultOutbound,
})
return true
} catch (e: any) {

View File

@@ -15,6 +15,7 @@ import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiIcon,
TuiOptGroup,
TuiTextfield,
} from '@taiga-ui/core'
@@ -24,32 +25,55 @@ 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'
@Component({
selector: 'tr[gateway]',
template: `
@if (gateway(); as gateway) {
<td class="name">
<td>
{{ gateway.name }}
</td>
<td class="type">
@if (gateway.ipInfo.deviceType; as type) {
{{ type }} ({{
gateway.public ? ('public' | i18n) : ('private' | i18n)
}})
} @else {
-
@if (gateway.isDefaultOutbound) {
<span tuiBadge tuiStatus appearance="positive">Default outbound</span>
}
</td>
<td>
@switch (gateway.ipInfo.deviceType) {
@case ('ethernet') {
<tui-icon icon="@tui.cable" />
{{ 'Ethernet' | i18n }}
}
@case ('wireless') {
<tui-icon icon="@tui.wifi" />
{{ 'WiFi' | i18n }}
}
@case ('wireguard') {
<tui-icon icon="@tui.shield" />
{{ 'WireGuard' | i18n }}
}
@default {
{{ gateway.ipInfo.deviceType }}
}
}
</td>
<td>
@if (gateway.type === 'outbound-only') {
<tui-icon icon="@tui.arrow-up-right" />
{{ 'Outbound Only' | i18n }}
} @else {
<tui-icon icon="@tui.arrow-left-right" />
{{ 'Inbound/Outbound' | i18n }}
}
</td>
<td class="lan">{{ gateway.lanIpv4.join(', ') }}</td>
<td
class="wan"
[style.color]="
gateway.ipInfo.wanIp ? 'var(--tui-text-warning)' : undefined
gateway.ipInfo.wanIp ? undefined : 'var(--tui-text-warning)'
"
>
{{ gateway.ipInfo.wanIp || ('Error' | i18n) }}
</td>
<td class="lan">{{ gateway.lanIpv4.join(', ') || '-' }}</td>
<td>
<button
tuiIconButton
@@ -67,6 +91,18 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
{{ 'Rename' | i18n }}
</button>
</tui-opt-group>
@if (!gateway.isDefaultOutbound) {
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.arrow-up-right"
(click)="setDefaultOutbound()"
>
{{ 'Set as Default Outbound' | i18n }}
</button>
</tui-opt-group>
}
@if (gateway.ipInfo.deviceType === 'wireguard') {
<tui-opt-group>
<button
@@ -87,19 +123,11 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
`,
styles: `
td:last-child {
grid-area: 1 / 3 / 5;
grid-area: 1 / 3 / 7;
align-self: center;
text-align: right;
}
.name {
width: 14rem;
}
.type {
width: 14rem;
}
:host-context(tui-root._mobile) {
grid-template-columns: min-content 1fr min-content;
@@ -107,11 +135,15 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
grid-column: span 2;
}
.type {
.connection {
grid-column: span 2;
order: -1;
}
.type {
grid-column: span 2;
}
.lan,
.wan {
grid-column: span 2;
@@ -132,9 +164,11 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
TuiButton,
TuiDropdown,
TuiDataList,
TuiIcon,
TuiOptGroup,
TuiTextfield,
i18nPipe,
TuiBadge,
],
})
export class GatewaysItemComponent {
@@ -166,6 +200,18 @@ 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

@@ -8,12 +8,21 @@ import { GatewayService } from 'src/app/services/gateway.service'
@Component({
selector: 'gateways-table',
template: `
<table [appTable]="['Name', 'Type', $any('LAN IP'), $any('WAN IP'), null]">
<table
[appTable]="[
'Name',
'Connection',
'Type',
$any('WAN IP'),
$any('LAN IP'),
null,
]"
>
@for (gateway of gatewayService.gateways(); track $index) {
<tr [gateway]="gateway"></tr>
} @empty {
<tr>
<td colspan="5">
<td colspan="7">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>

View File

@@ -2253,6 +2253,7 @@ export namespace Mock {
},
},
storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
tasks: {
@@ -2321,6 +2322,7 @@ export namespace Mock {
},
hosts: {},
storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
tasks: {},
@@ -2427,6 +2429,7 @@ export namespace Mock {
},
hosts: {},
storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
tasks: {

View File

@@ -252,10 +252,13 @@ export namespace RR {
// network
export type GatewayType = 'inbound-outbound' | 'outbound-only'
export type AddTunnelReq = {
name: string
config: string // file contents
public: boolean
type: GatewayType
setAsDefaultOutbound?: boolean
} // net.tunnel.add
export type AddTunnelRes = {
id: string
@@ -270,6 +273,17 @@ export namespace RR {
export type RemoveTunnelReq = { id: string } // net.tunnel.remove
export type RemoveTunnelRes = null
// Set default outbound gateway
export type SetDefaultOutboundReq = { gateway: string | null } // net.gateway.set-default-outbound
export type SetDefaultOutboundRes = null
// Set service outbound gateway
export type SetServiceOutboundReq = {
packageId: string
gateway: string | null
} // package.set-outbound-gateway
export type SetServiceOutboundRes = null
export type InitAcmeReq = {
provider: string
contact: string[]

View File

@@ -175,6 +175,14 @@ export abstract class ApiService {
abstract removeTunnel(params: RR.RemoveTunnelReq): Promise<RR.RemoveTunnelRes>
abstract setDefaultOutbound(
params: RR.SetDefaultOutboundReq,
): Promise<RR.SetDefaultOutboundRes>
abstract setServiceOutbound(
params: RR.SetServiceOutboundReq,
): Promise<RR.SetServiceOutboundRes>
// ** domains **
// wifi

View File

@@ -355,6 +355,18 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'net.tunnel.remove', params })
}
async setDefaultOutbound(
params: RR.SetDefaultOutboundReq,
): Promise<RR.SetDefaultOutboundRes> {
return this.rpcRequest({ method: 'net.gateway.set-default-outbound', params })
}
async setServiceOutbound(
params: RR.SetServiceOutboundReq,
): Promise<RR.SetServiceOutboundRes> {
return this.rpcRequest({ method: 'package.set-outbound-gateway', params })
}
// wifi
async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> {

View File

@@ -566,13 +566,12 @@ export class MockApiService extends ApiService {
const id = `wg${this.proxyId++}`
const patch: AddOperation<T.NetworkInterfaceInfo>[] = [
const patch: AddOperation<any>[] = [
{
op: PatchOp.ADD,
path: `/serverInfo/network/gateways/${id}`,
value: {
name: params.name,
public: params.public,
secure: false,
ipInfo: {
name: id,
@@ -584,9 +583,19 @@ export class MockApiService extends ApiService {
lanIp: ['192.168.1.10'],
dnsServers: [],
},
type: params.type,
},
},
]
if (params.setAsDefaultOutbound) {
;(patch as any[]).push({
op: PatchOp.REPLACE,
path: '/serverInfo/network/defaultOutbound',
value: id,
})
}
this.mockRevision(patch)
return { id }
@@ -620,6 +629,38 @@ export class MockApiService extends ApiService {
return null
}
async setDefaultOutbound(
params: RR.SetDefaultOutboundReq,
): Promise<RR.SetDefaultOutboundRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/network/defaultOutbound',
value: params.gateway,
},
]
this.mockRevision(patch)
return null
}
async setServiceOutbound(
params: RR.SetServiceOutboundReq,
): Promise<RR.SetServiceOutboundRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.packageId}/outboundGateway`,
value: params.gateway,
},
]
this.mockRevision(patch)
return null
}
// wifi
async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> {

View File

@@ -124,8 +124,8 @@ export const mockPatchData: DataModel = {
gateways: {
eth0: {
name: null,
public: null,
secure: null,
type: null,
ipInfo: {
name: 'Wired Connection 1',
scopeId: 1,
@@ -139,8 +139,8 @@ export const mockPatchData: DataModel = {
},
wlan0: {
name: null,
public: null,
secure: null,
type: null,
ipInfo: {
name: 'Wireless Connection 1',
scopeId: 2,
@@ -157,8 +157,8 @@ export const mockPatchData: DataModel = {
},
wireguard1: {
name: 'StartTunnel',
public: null,
secure: null,
type: 'inbound-outbound',
ipInfo: {
name: 'wireguard1',
scopeId: 2,
@@ -173,7 +173,23 @@ export const mockPatchData: DataModel = {
dnsServers: ['1.1.1.1'],
},
},
wireguard2: {
name: 'Mullvad VPN',
secure: null,
type: 'outbound-only',
ipInfo: {
name: 'wireguard2',
scopeId: 4,
deviceType: 'wireguard',
subnets: [],
wanIp: '198.51.100.77',
ntpServers: [],
lanIp: [],
dnsServers: ['10.64.0.1'],
},
},
},
defaultOutbound: 'eth0',
dns: {
dhcpServers: ['1.1.1.1', '8.8.8.8'],
staticServers: null,
@@ -320,6 +336,7 @@ export const mockPatchData: DataModel = {
},
hosts: {},
storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
tasks: {
@@ -624,6 +641,7 @@ export const mockPatchData: DataModel = {
},
},
storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
tasks: {

View File

@@ -4,6 +4,7 @@ import {
ErrorService,
i18nKey,
i18nPipe,
i18nService,
LoadingService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@@ -24,6 +25,7 @@ export class ControlsService {
private readonly api = inject(ApiService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly i18n = inject(i18nPipe)
private readonly i18nService = inject(i18nService)
async start({ title, alerts, id }: T.Manifest, unmet: boolean) {
const deps =
@@ -31,7 +33,7 @@ export class ControlsService {
if (
(unmet && !(await this.alert(deps))) ||
(alerts.start && !(await this.alert(alerts.start as i18nKey)))
(alerts.start && !(await this.alert(alerts.start)))
) {
return
}
@@ -49,7 +51,7 @@ export class ControlsService {
async stop({ id, title, alerts }: T.Manifest) {
const depMessage = `${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
let content = alerts.stop || ''
let content = alerts.stop ? this.i18nService.localize(alerts.stop) : ''
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
content = content ? `${content}.\n\n${depMessage}` : depMessage
@@ -113,14 +115,14 @@ export class ControlsService {
})
}
private alert(content: i18nKey): Promise<boolean> {
private alert(content: T.LocaleString): Promise<boolean> {
return firstValueFrom(
this.dialog
.openConfirm({
label: 'Warning',
size: 's',
data: {
content,
content: this.i18nService.localize(content),
yes: 'Continue',
no: 'Cancel',
},

View File

@@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { T, utils } from '@start9labs/start-sdk'
import { map } from 'rxjs/operators'
import { map } from 'rxjs'
import { DataModel } from './patch-db/data-model'
import { toSignal } from '@angular/core/rxjs-interop'
@@ -12,39 +12,47 @@ export type GatewayPlus = T.NetworkInterfaceInfo & {
subnets: utils.IpNet[]
lanIpv4: string[]
wanIp?: utils.IpAddress
public: boolean
isDefaultOutbound: boolean
}
@Injectable()
export class GatewayService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly network$ = this.patch.watch$('serverInfo', 'network')
readonly defaultOutbound = toSignal(
this.network$.pipe(map(n => n.defaultOutbound)),
)
readonly gateways = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'gateways')
.pipe(
map(gateways =>
Object.entries(gateways)
.filter(([_, val]) => !!val?.ipInfo)
.filter(
([_, val]) =>
val?.ipInfo?.deviceType !== 'bridge' &&
val?.ipInfo?.deviceType !== 'loopback',
)
.map(([id, val]) => {
const subnets =
val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) ?? []
const name = val.name ?? val.ipInfo!.name
return {
...val,
id,
name,
subnets,
lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address),
public: val.public ?? subnets.some(s => s.isPublic()),
wanIp:
val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp),
} as GatewayPlus
}),
),
),
this.network$.pipe(
map(network => {
const gateways = network.gateways
const defaultOutbound = network.defaultOutbound
return Object.entries(gateways)
.filter(([_, val]) => !!val?.ipInfo)
.filter(
([_, val]) =>
val?.ipInfo?.deviceType !== 'bridge' &&
val?.ipInfo?.deviceType !== 'loopback',
)
.map(([id, val]) => {
const subnets =
val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) ?? []
const name = val.name ?? val.ipInfo!.name
return {
...val,
id,
name,
subnets,
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

@@ -31,7 +31,7 @@ export function getInstalledBaseStatus(statusInfo: T.StatusInfo): BaseStatus {
(!statusInfo.started ||
Object.values(statusInfo.health)
.filter(h => !!h)
.some(h => h.result === 'starting'))
.some(h => h.result === 'starting' || h.result === 'waiting'))
) {
return 'starting'
}

View File

@@ -5,6 +5,7 @@ import {
ErrorService,
i18nKey,
i18nPipe,
i18nService,
LoadingService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@@ -27,6 +28,7 @@ export class StandardActionsService {
private readonly loader = inject(LoadingService)
private readonly router = inject(Router)
private readonly i18n = inject(i18nPipe)
private readonly i18nService = inject(i18nService)
async rebuild(id: string) {
const loader = this.loader.open('Rebuilding container').subscribe()
@@ -50,11 +52,12 @@ export class StandardActionsService {
): Promise<void> {
let content = soft
? ''
: alerts.uninstall ||
`${this.i18n.transform('Uninstalling')} ${title} ${this.i18n.transform('will permanently delete its data.')}`
: alerts.uninstall
? this.i18nService.localize(alerts.uninstall)
: `${this.i18n.transform('Uninstalling')} ${title} ${this.i18n.transform('will permanently delete its data.')}`
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
content = `${content}${content ? ' ' : ''}${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
content = `${content ? `${content} ` : ''}${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
}
if (!content) {