mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
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:
@@ -12,7 +12,7 @@
|
||||
{{ pkg.title }}
|
||||
</span>
|
||||
<span class="detail-description">
|
||||
{{ pkg.description.short }}
|
||||
{{ pkg.description.short | localize }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
|
||||
import { LocalizePipe, SharedPipesModule, TickerComponent } from '@start9labs/shared'
|
||||
import { ItemComponent } from './item.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ItemComponent],
|
||||
exports: [ItemComponent],
|
||||
imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent],
|
||||
imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent, LocalizePipe],
|
||||
})
|
||||
export class ItemModule {}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
output,
|
||||
} from '@angular/core'
|
||||
import { MarketplacePkgBase } from '../../types'
|
||||
import { CopyService, i18nPipe } from '@start9labs/shared'
|
||||
import { CopyService, i18nPipe, LocalizePipe } from '@start9labs/shared'
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { MarketplaceItemComponent } from './item.component'
|
||||
|
||||
@@ -71,7 +71,7 @@ import { MarketplaceItemComponent } from './item.component'
|
||||
<div class="background-border box-shadow-lg shadow-color-light">
|
||||
<div class="box-container">
|
||||
<h2 class="additional-detail-title">{{ 'Description' | i18n }}</h2>
|
||||
<p [innerHTML]="pkg().description.long"></p>
|
||||
<p [innerHTML]="pkg().description.long | localize"></p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -129,7 +129,7 @@ import { MarketplaceItemComponent } from './item.component'
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MarketplaceItemComponent, DatePipe, i18nPipe],
|
||||
imports: [MarketplaceItemComponent, DatePipe, i18nPipe, LocalizePipe],
|
||||
})
|
||||
export class MarketplaceAboutComponent {
|
||||
readonly copyService = inject(CopyService)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
|
||||
import { injectContext } from '@taiga-ui/polymorpheus'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [TuiButton, i18nPipe],
|
||||
template: `
|
||||
<div class="animation-container">
|
||||
<div class="port">
|
||||
<div class="port-inner"></div>
|
||||
</div>
|
||||
<div class="usb-stick">
|
||||
<div class="usb-connector"></div>
|
||||
<div class="usb-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
{{
|
||||
'Remove USB stick or other installation media from your server' | i18n
|
||||
}}
|
||||
</p>
|
||||
<footer>
|
||||
<button tuiButton (click)="context.completeWith(true)">
|
||||
{{ 'Done' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.animation-container {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 69px;
|
||||
}
|
||||
|
||||
.port {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 18px;
|
||||
background: var(--tui-background-neutral-1);
|
||||
border: 2px solid var(--tui-border-normal);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.port-inner {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
background: var(--tui-background-neutral-2);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.usb-stick {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
animation: slide-out 2s ease-in-out 0.5s infinite;
|
||||
left: 32px;
|
||||
}
|
||||
|
||||
.usb-connector {
|
||||
width: 20px;
|
||||
height: 12px;
|
||||
background: var(--tui-text-secondary);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.usb-body {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
background: var(--tui-status-info);
|
||||
border-radius: 2px 4px 4px 2px;
|
||||
}
|
||||
|
||||
@keyframes slide-out {
|
||||
0% {
|
||||
left: 32px;
|
||||
opacity: 0;
|
||||
}
|
||||
5% {
|
||||
left: 32px;
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
left: 130px;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
left: 130px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class RemoveMediaDialog {
|
||||
protected readonly context = injectContext<TuiDialogContext<boolean>>()
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ChangeDetectorRef, Component, inject } from '@angular/core'
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
HostListener,
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
@@ -21,13 +26,14 @@ import {
|
||||
import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { filter } from 'rxjs'
|
||||
import { filter, Subscription } from 'rxjs'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@if (!shuttingDown) {
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||
@@ -132,6 +138,7 @@ import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog
|
||||
}
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.no-drives {
|
||||
@@ -176,6 +183,14 @@ export default class DrivesPage {
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent) {
|
||||
if (event.ctrlKey && event.shiftKey && event.key === 'X') {
|
||||
event.preventDefault()
|
||||
this.shutdownServer()
|
||||
}
|
||||
}
|
||||
|
||||
readonly osDriveTooltip = this.i18n.transform(
|
||||
'The drive where the StartOS operating system will be installed.',
|
||||
)
|
||||
@@ -185,6 +200,8 @@ export default class DrivesPage {
|
||||
|
||||
drives: DiskInfo[] = []
|
||||
loading = true
|
||||
shuttingDown = false
|
||||
private dialogSub?: Subscription
|
||||
selectedOsDrive: DiskInfo | null = null
|
||||
selectedDataDrive: DiskInfo | null = null
|
||||
preserveData: boolean | null = null
|
||||
@@ -339,22 +356,18 @@ export default class DrivesPage {
|
||||
loader.unsubscribe()
|
||||
|
||||
// Show success dialog
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
this.dialogSub = this.dialogs
|
||||
.openAlert('StartOS has been installed successfully.', {
|
||||
label: 'Installation Complete!',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'StartOS has been installed successfully.',
|
||||
yes: 'Continue to Setup',
|
||||
no: 'Shutdown',
|
||||
},
|
||||
dismissible: false,
|
||||
closeable: true,
|
||||
data: { button: this.i18n.transform('Continue to Setup') },
|
||||
})
|
||||
.subscribe(continueSetup => {
|
||||
if (continueSetup) {
|
||||
.subscribe({
|
||||
complete: () => {
|
||||
this.navigateToNextStep(result.attach)
|
||||
} else {
|
||||
this.shutdownServer()
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (e: any) {
|
||||
loader.unsubscribe()
|
||||
@@ -372,10 +385,12 @@ export default class DrivesPage {
|
||||
}
|
||||
|
||||
private async shutdownServer() {
|
||||
this.dialogSub?.unsubscribe()
|
||||
const loader = this.loader.open('Beginning shutdown').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.shutdown()
|
||||
this.shuttingDown = true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
ViewChild,
|
||||
DOCUMENT,
|
||||
} from '@angular/core'
|
||||
import { DownloadHTMLService, ErrorService, i18nPipe } from '@start9labs/shared'
|
||||
import {
|
||||
DialogService,
|
||||
DownloadHTMLService,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiAvatar } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||
@@ -14,7 +19,9 @@ import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { DocumentationComponent } from '../components/documentation.component'
|
||||
import { MatrixComponent } from '../components/matrix.component'
|
||||
import { RemoveMediaDialog } from '../components/remove-media.dialog'
|
||||
import { SetupCompleteRes } from '../types'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@@ -29,12 +36,8 @@ import { SetupCompleteRes } from '../types'
|
||||
@if (!stateService.kiosk) {
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
stateService.setupType === 'restore'
|
||||
? ('You can unplug your backup drive' | i18n)
|
||||
: stateService.setupType === 'transfer'
|
||||
? ('You can unplug your transfer drive' | i18n)
|
||||
: ('http://start.local was for setup only. It will no longer work.'
|
||||
| i18n)
|
||||
'http://start.local was for setup only. It will no longer work.'
|
||||
| i18n
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
@@ -69,14 +72,15 @@ import { SetupCompleteRes } from '../types'
|
||||
tuiCell="l"
|
||||
[class.disabled]="!stateService.kiosk && !downloaded"
|
||||
[disabled]="(!stateService.kiosk && !downloaded) || usbRemoved"
|
||||
(click)="usbRemoved = true"
|
||||
(click)="removeMedia()"
|
||||
>
|
||||
<tui-avatar appearance="secondary" src="@tui.usb" />
|
||||
<div tuiTitle>
|
||||
{{ 'USB Removed' | i18n }}
|
||||
{{ 'Remove Installation Media' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{
|
||||
'Remove the USB installation media from your server' | i18n
|
||||
'Remove USB stick or other installation media from your server'
|
||||
| i18n
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,6 +188,7 @@ export default class SuccessPage implements AfterViewInit {
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly downloadHtml = inject(DownloadHTMLService)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
readonly stateService = inject(StateService)
|
||||
@@ -225,6 +230,21 @@ export default class SuccessPage implements AfterViewInit {
|
||||
})
|
||||
}
|
||||
|
||||
removeMedia() {
|
||||
this.dialogs
|
||||
.openComponent<boolean>(
|
||||
new PolymorpheusComponent(RemoveMediaDialog),
|
||||
{
|
||||
size: 's',
|
||||
dismissible: false,
|
||||
closeable: false,
|
||||
},
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.usbRemoved = true
|
||||
})
|
||||
}
|
||||
|
||||
exitKiosk() {
|
||||
this.api.exit()
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export default {
|
||||
102: 'Verlassen',
|
||||
103: 'Sind Sie sicher?',
|
||||
104: 'Neues Netzwerk-Gateway',
|
||||
107: 'Onion-Domains',
|
||||
108: 'Öffentlich',
|
||||
109: 'privat',
|
||||
111: 'Keine Onion-Domains',
|
||||
@@ -639,13 +640,11 @@ export default {
|
||||
667: 'Einrichtung wird gestartet',
|
||||
670: 'Warten Sie 1–2 Minuten und aktualisieren Sie die Seite',
|
||||
672: 'Einrichtung abgeschlossen!',
|
||||
673: 'Sie können Ihr Backup-Laufwerk entfernen',
|
||||
674: 'Sie können Ihr Übertragungs-Laufwerk entfernen',
|
||||
675: 'http://start.local war nur für die Einrichtung gedacht. Es funktioniert nicht mehr.',
|
||||
676: 'Adressinformationen herunterladen',
|
||||
677: 'Enthält die permanente lokale Adresse Ihres Servers und die Root-CA',
|
||||
678: 'USB entfernt',
|
||||
679: 'Entfernen Sie das USB-Installationsmedium aus Ihrem Server',
|
||||
678: 'Installationsmedium entfernen',
|
||||
679: 'Entfernen Sie den USB-Stick oder andere Installationsmedien von Ihrem Server',
|
||||
680: 'Server neu starten',
|
||||
681: 'Warten, bis der Server wieder online ist',
|
||||
682: 'Server ist wieder online',
|
||||
@@ -680,4 +679,16 @@ export default {
|
||||
714: 'Installation abgeschlossen!',
|
||||
715: 'StartOS wurde erfolgreich installiert.',
|
||||
716: 'Weiter zur Einrichtung',
|
||||
717: '',
|
||||
718: '',
|
||||
719: '',
|
||||
720: '',
|
||||
721: '',
|
||||
722: '',
|
||||
723: '',
|
||||
724: '',
|
||||
725: '',
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -99,6 +99,7 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Leave': 102,
|
||||
'Are you sure?': 103,
|
||||
'New gateway': 104, // as in, a network gateway
|
||||
'Tor Domains': 107,
|
||||
'public': 108,
|
||||
'private': 109,
|
||||
'No Tor domains': 111,
|
||||
@@ -639,13 +640,11 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Starting setup': 667,
|
||||
'Wait 1-2 minutes and refresh the page': 670,
|
||||
'Setup Complete!': 672,
|
||||
'You can unplug your backup drive': 673,
|
||||
'You can unplug your transfer drive': 674,
|
||||
'http://start.local was for setup only. It will no longer work.': 675,
|
||||
'Download Address Info': 676,
|
||||
"Contains your server's permanent local address and Root CA": 677,
|
||||
'USB Removed': 678,
|
||||
'Remove the USB installation media from your server': 679,
|
||||
'Remove Installation Media': 678,
|
||||
'Remove USB stick or other installation media from your server': 679,
|
||||
'Restart Server': 680,
|
||||
'Waiting for server to come back online': 681,
|
||||
'Server is back online': 682,
|
||||
@@ -680,4 +679,16 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Installation Complete!': 714,
|
||||
'StartOS has been installed successfully.': 715,
|
||||
'Continue to Setup': 716,
|
||||
'Set Outbound Gateway': 717,
|
||||
'Current': 718,
|
||||
'System default)': 719,
|
||||
'Outbound Gateway': 720,
|
||||
'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,
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export default {
|
||||
102: 'Salir',
|
||||
103: '¿Estás seguro?',
|
||||
104: 'Nueva puerta de enlace de red',
|
||||
107: 'dominios onion',
|
||||
108: 'público',
|
||||
109: 'privado',
|
||||
111: 'Sin dominios onion',
|
||||
@@ -639,13 +640,11 @@ export default {
|
||||
667: 'Iniciando configuración',
|
||||
670: 'Espere 1–2 minutos y actualice la página',
|
||||
672: '¡Configuración completa!',
|
||||
673: 'Puede desconectar su unidad de copia de seguridad',
|
||||
674: 'Puede desconectar su unidad de transferencia',
|
||||
675: 'http://start.local era solo para la configuración. Ya no funcionará.',
|
||||
676: 'Descargar información de direcciones',
|
||||
677: 'Contiene la dirección local permanente de su servidor y la CA raíz',
|
||||
678: 'USB retirado',
|
||||
679: 'Retire el medio de instalación USB de su servidor',
|
||||
678: 'Retirar medio de instalación',
|
||||
679: 'Retire la memoria USB u otro medio de instalación de su servidor',
|
||||
680: 'Reiniciar servidor',
|
||||
681: 'Esperando a que el servidor vuelva a estar en línea',
|
||||
682: 'El servidor ha vuelto a estar en línea',
|
||||
@@ -680,4 +679,16 @@ export default {
|
||||
714: '¡Instalación completada!',
|
||||
715: 'StartOS se ha instalado correctamente.',
|
||||
716: 'Continuar con la configuración',
|
||||
717: '',
|
||||
718: '',
|
||||
719: '',
|
||||
720: '',
|
||||
721: '',
|
||||
722: '',
|
||||
723: '',
|
||||
724: '',
|
||||
725: '',
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -100,6 +100,7 @@ export default {
|
||||
102: 'Quitter',
|
||||
103: 'Êtes-vous sûr ?',
|
||||
104: 'Nouvelle passerelle réseau',
|
||||
107: 'domaine onion',
|
||||
108: 'public',
|
||||
109: 'privé',
|
||||
111: 'Aucune domaine onion',
|
||||
@@ -639,13 +640,11 @@ export default {
|
||||
667: 'Démarrage de la configuration',
|
||||
670: 'Attendez 1 à 2 minutes puis actualisez la page',
|
||||
672: 'Configuration terminée !',
|
||||
673: 'Vous pouvez débrancher votre disque de sauvegarde',
|
||||
674: 'Vous pouvez débrancher votre disque de transfert',
|
||||
675: 'http://start.local était réservé à la configuration. Il ne fonctionnera plus.',
|
||||
676: 'Télécharger les informations d’adresse',
|
||||
677: 'Contient l’adresse locale permanente de votre serveur et la CA racine',
|
||||
678: 'USB retiré',
|
||||
679: 'Retirez le support d’installation USB de votre serveur',
|
||||
677: 'Contient l\u2019adresse locale permanente de votre serveur et la CA racine',
|
||||
678: 'Retirer le support d\u2019installation',
|
||||
679: 'Retirez la clé USB ou tout autre support d\u2019installation de votre serveur',
|
||||
680: 'Redémarrer le serveur',
|
||||
681: 'En attente du retour en ligne du serveur',
|
||||
682: 'Le serveur est de nouveau en ligne',
|
||||
@@ -680,4 +679,16 @@ export default {
|
||||
714: 'Installation terminée !',
|
||||
715: 'StartOS a été installé avec succès.',
|
||||
716: 'Continuer vers la configuration',
|
||||
717: '',
|
||||
718: '',
|
||||
719: '',
|
||||
720: '',
|
||||
721: '',
|
||||
722: '',
|
||||
723: '',
|
||||
724: '',
|
||||
725: '',
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -100,6 +100,7 @@ export default {
|
||||
102: 'Opuść',
|
||||
103: 'Czy jesteś pewien?',
|
||||
104: 'Nowa brama sieciowa',
|
||||
107: 'domeny onion',
|
||||
108: 'publiczny',
|
||||
109: 'prywatny',
|
||||
111: 'Brak domeny onion',
|
||||
@@ -639,13 +640,11 @@ export default {
|
||||
667: 'Rozpoczynanie konfiguracji',
|
||||
670: 'Poczekaj 1–2 minuty i odśwież stronę',
|
||||
672: 'Konfiguracja zakończona!',
|
||||
673: 'Możesz odłączyć dysk kopii zapasowej',
|
||||
674: 'Możesz odłączyć dysk transferowy',
|
||||
675: 'http://start.local służył tylko do konfiguracji. Nie będzie już działać.',
|
||||
676: 'Pobierz informacje adresowe',
|
||||
677: 'Zawiera stały lokalny adres serwera oraz główny urząd certyfikacji (Root CA)',
|
||||
678: 'USB usunięty',
|
||||
679: 'Usuń instalacyjny nośnik USB z serwera',
|
||||
678: 'Usuń nośnik instalacyjny',
|
||||
679: 'Usuń pamięć USB lub inny nośnik instalacyjny z serwera',
|
||||
680: 'Uruchom ponownie serwer',
|
||||
681: 'Oczekiwanie na ponowne połączenie serwera',
|
||||
682: 'Serwer jest ponownie online',
|
||||
@@ -680,4 +679,16 @@ export default {
|
||||
714: 'Instalacja zakończona!',
|
||||
715: 'StartOS został pomyślnie zainstalowany.',
|
||||
716: 'Przejdź do konfiguracji',
|
||||
717: '',
|
||||
718: '',
|
||||
719: '',
|
||||
720: '',
|
||||
721: '',
|
||||
722: '',
|
||||
723: '',
|
||||
724: '',
|
||||
725: '',
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { inject, Injectable, Pipe, PipeTransform } from '@angular/core'
|
||||
import { i18nService } from './i18n.service'
|
||||
import { I18N } from './i18n.providers'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
@Pipe({
|
||||
@@ -9,8 +10,10 @@ import { T } from '@start9labs/start-sdk'
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LocalizePipe implements PipeTransform {
|
||||
private readonly i18nService = inject(i18nService)
|
||||
private readonly i18n = inject(I18N)
|
||||
|
||||
transform(string: T.LocaleString): string {
|
||||
this.i18n() // read signal to trigger change detection on language switch
|
||||
return this.i18nService.localize(string)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ export const mockTunnelData: TunnelData = {
|
||||
gateways: {
|
||||
eth0: {
|
||||
name: null,
|
||||
public: null,
|
||||
secure: null,
|
||||
type: null,
|
||||
ipInfo: {
|
||||
name: 'Wired Connection 1',
|
||||
scopeId: 1,
|
||||
|
||||
@@ -15,7 +15,6 @@ import { TuiBadge } from '@taiga-ui/kit'
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
clip-path: inset(0 round 0.75rem);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user