task fix and keyboard fix (#3130)

* task fix and keyboard fix

* fixes for build scripts

* passthrough feature

* feat: inline domain health checks and improve address UX

- addPublicDomain returns DNS query + port check results (AddPublicDomainRes)
  so frontend skips separate API calls after adding a domain
- addPrivateDomain returns check_dns result for the gateway
- Support multiple ports per domain in validation modal (deduplicated)
- Run port checks concurrently via futures::future::join_all
- Add note to add-domain dialog showing other interfaces on same host
- Add addXForwardedHeaders to knownProtocols in SDK Host.ts
- Add plugin filter kind, pluginId filter, matchesAny, and docs to
  getServiceInterface.ts
- Add PassthroughInfo type and passthroughs field to NetworkInfo
- Pluralize "port forwarding rules" in i18n dictionaries

* feat: add shared host note to private domain dialog with i18n

* fix: scope public domain to single binding and return single port check

Accept internalPort in AddPublicDomainParams to target a specific
binding. Disable the domain on all other bindings. Return a single
CheckPortRes instead of Vec. Revert multi-port UI to singular port
display from 0f8a66b35.

* better shared hostname approach,  and improve look-feel of addresses tables

* fix starttls

* preserve usb as top efi boot option

* fix race condition in wan ip check

* sdk beta.56

* various bug, improve smtp

* multiple bugs, better outbound gateway UX

* remove non option from smtp for better package compat

* bump sdk

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Matt Hill
2026-03-06 00:30:06 -07:00
committed by GitHub
parent 3320391fcc
commit 8b89f016ad
72 changed files with 2075 additions and 759 deletions

View File

@@ -34,110 +34,121 @@ import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog
@Component({
template: `
@if (!shuttingDown) {
<section tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
</header>
<section tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
</header>
@if (loading) {
<tui-loader />
} @else if (drives.length === 0) {
<p class="no-drives">
{{
'No drives found. Please connect a drive and click Refresh.' | i18n
}}
</p>
} @else {
<tui-textfield [stringify]="stringify">
<label tuiLabel>{{ 'OS Drive' | i18n }}</label>
@if (mobile) {
<select
tuiSelect
[(ngModel)]="selectedOsDrive"
[items]="drives"
></select>
} @else {
<input tuiSelect [(ngModel)]="selectedOsDrive" />
}
@if (!mobile) {
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="drives"
[itemContent]="driveContent"
/>
}
<tui-icon [tuiTooltip]="osDriveTooltip" />
</tui-textfield>
<tui-textfield [stringify]="stringify">
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
@if (mobile) {
<select
tuiSelect
[(ngModel)]="selectedDataDrive"
(ngModelChange)="onDataDriveChange($event)"
[items]="drives"
></select>
} @else {
<input
tuiSelect
[(ngModel)]="selectedDataDrive"
(ngModelChange)="onDataDriveChange($event)"
/>
}
@if (!mobile) {
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="drives"
[itemContent]="driveContent"
/>
}
@if (preserveData === true) {
<tui-icon
icon="@tui.database"
style="color: var(--tui-status-positive); pointer-events: none"
/>
}
@if (preserveData === false) {
<tui-icon
icon="@tui.database-zap"
style="color: var(--tui-status-negative); pointer-events: none"
/>
}
<tui-icon [tuiTooltip]="dataDriveTooltip" />
</tui-textfield>
<ng-template #driveContent let-drive>
<div class="drive-item">
<span class="drive-name">
{{ drive.vendor || ('Unknown' | i18n) }}
{{ drive.model || ('Drive' | i18n) }}
</span>
<small>
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
</small>
</div>
</ng-template>
}
<footer>
@if (drives.length === 0) {
<button tuiButton appearance="secondary" (click)="refresh()">
{{ 'Refresh' | i18n }}
</button>
@if (loading) {
<tui-loader />
} @else if (drives.length === 0) {
<p class="no-drives">
{{
'No drives found. Please connect a drive and click Refresh.'
| i18n
}}
</p>
} @else {
<button
tuiButton
[disabled]="!selectedOsDrive || !selectedDataDrive"
(click)="continue()"
<tui-textfield
[stringify]="stringify"
[disabledItemHandler]="osDisabled"
>
{{ 'Continue' | i18n }}
</button>
<label tuiLabel>{{ 'OS Drive' | i18n }}</label>
@if (mobile) {
<select
tuiSelect
[ngModel]="selectedOsDrive"
(ngModelChange)="onOsDriveChange($event)"
[items]="drives"
></select>
} @else {
<input
tuiSelect
[ngModel]="selectedOsDrive"
(ngModelChange)="onOsDriveChange($event)"
/>
}
@if (!mobile) {
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="drives"
[itemContent]="driveContent"
/>
}
<tui-icon [tuiTooltip]="osDriveTooltip" />
</tui-textfield>
<tui-textfield
[stringify]="stringify"
[disabledItemHandler]="dataDisabled"
>
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
@if (mobile) {
<select
tuiSelect
[(ngModel)]="selectedDataDrive"
(ngModelChange)="onDataDriveChange($event)"
[items]="drives"
></select>
} @else {
<input
tuiSelect
[(ngModel)]="selectedDataDrive"
(ngModelChange)="onDataDriveChange($event)"
/>
}
@if (!mobile) {
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="drives"
[itemContent]="driveContent"
/>
}
@if (preserveData === true) {
<tui-icon
icon="@tui.database"
style="color: var(--tui-status-positive); pointer-events: none"
/>
}
@if (preserveData === false) {
<tui-icon
icon="@tui.database-zap"
style="color: var(--tui-status-negative); pointer-events: none"
/>
}
<tui-icon [tuiTooltip]="dataDriveTooltip" />
</tui-textfield>
<ng-template #driveContent let-drive>
<div class="drive-item">
<span class="drive-name">
{{ driveName(drive) }}
</span>
<small>
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
</small>
</div>
</ng-template>
}
</footer>
</section>
<footer>
@if (drives.length === 0) {
<button tuiButton appearance="secondary" (click)="refresh()">
{{ 'Refresh' | i18n }}
</button>
} @else {
<button
tuiButton
[disabled]="!selectedOsDrive || !selectedDataDrive"
(click)="continue()"
>
{{ 'Continue' | i18n }}
</button>
}
</footer>
</section>
}
`,
styles: `
@@ -198,6 +209,10 @@ export default class DrivesPage {
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.',
)
private readonly MIN_OS = 18 * 2 ** 30 // 18 GiB
private readonly MIN_DATA = 20 * 2 ** 30 // 20 GiB
private readonly MIN_BOTH = 38 * 2 ** 30 // 38 GiB
drives: DiskInfo[] = []
loading = true
shuttingDown = false
@@ -206,10 +221,17 @@ export default class DrivesPage {
selectedDataDrive: DiskInfo | null = null
preserveData: boolean | null = null
readonly osDisabled = (drive: DiskInfo): boolean =>
drive.capacity < this.MIN_OS
dataDisabled = (drive: DiskInfo): boolean => drive.capacity < this.MIN_DATA
readonly driveName = (drive: DiskInfo): string =>
[drive.vendor, drive.model].filter(Boolean).join(' ') ||
this.i18n.transform('Unknown Drive')
readonly stringify = (drive: DiskInfo | null) =>
drive
? `${drive.vendor || this.i18n.transform('Unknown')} ${drive.model || this.i18n.transform('Drive')}`
: ''
drive ? this.driveName(drive) : ''
formatCapacity(bytes: number): string {
const gb = bytes / 1e9
@@ -231,6 +253,22 @@ export default class DrivesPage {
await this.loadDrives()
}
onOsDriveChange(osDrive: DiskInfo | null) {
this.selectedOsDrive = osDrive
this.dataDisabled = (drive: DiskInfo) => {
if (osDrive && drive.logicalname === osDrive.logicalname) {
return drive.capacity < this.MIN_BOTH
}
return drive.capacity < this.MIN_DATA
}
// Clear data drive if it's now invalid
if (this.selectedDataDrive && this.dataDisabled(this.selectedDataDrive)) {
this.selectedDataDrive = null
this.preserveData = null
}
}
onDataDriveChange(drive: DiskInfo | null) {
this.preserveData = null
@@ -400,7 +438,7 @@ export default class DrivesPage {
private async loadDrives() {
try {
this.drives = await this.api.getDisks()
this.drives = (await this.api.getDisks()).filter(d => d.capacity > 0)
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -1,5 +1,4 @@
import { Component, inject, signal } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms'
import {
getAllKeyboardsSorted,
@@ -72,7 +71,6 @@ import { StateService } from '../services/state.service'
],
})
export default class KeyboardPage {
private readonly router = inject(Router)
private readonly api = inject(ApiService)
private readonly stateService = inject(StateService)
@@ -103,22 +101,9 @@ export default class KeyboardPage {
})
this.stateService.keyboard = this.selected.layout
await this.navigateToNextStep()
await this.stateService.navigateAfterLocale()
} finally {
this.saving.set(false)
}
}
private async navigateToNextStep() {
if (this.stateService.dataDriveGuid) {
if (this.stateService.attach) {
this.stateService.setupType = 'attach'
await this.router.navigate(['/password'])
} else {
await this.router.navigate(['/home'])
}
} else {
await this.router.navigate(['/drives'])
}
}
}

View File

@@ -141,8 +141,12 @@ export default class LanguagePage {
try {
await this.api.setLanguage({ language: this.selected.name })
// Always go to keyboard selection
await this.router.navigate(['/keyboard'])
if (this.stateService.kiosk) {
await this.router.navigate(['/keyboard'])
} else {
await this.stateService.navigateAfterLocale()
}
} finally {
this.saving.set(false)
}

View File

@@ -191,7 +191,118 @@ export class MockApiService extends ApiService {
}
}
const GiB = 2 ** 30
const MOCK_DISKS: DiskInfo[] = [
// 0 capacity - should be hidden entirely
{
logicalname: '/dev/sdd',
vendor: 'Generic',
model: 'Card Reader',
partitions: [],
capacity: 0,
guid: null,
},
// 10 GiB - too small for OS and data; also tests both vendor+model null
{
logicalname: '/dev/sde',
vendor: null,
model: null,
partitions: [
{
logicalname: '/dev/sde1',
label: null,
capacity: 10 * GiB,
used: null,
startOs: {},
guid: null,
},
],
capacity: 10 * GiB,
guid: null,
},
// 18 GiB - exact OS boundary; tests vendor null with model present
{
logicalname: '/dev/sdf',
vendor: null,
model: 'SATA Flash Drive',
partitions: [
{
logicalname: '/dev/sdf1',
label: null,
capacity: 18 * GiB,
used: null,
startOs: {},
guid: null,
},
],
capacity: 18 * GiB,
guid: null,
},
// 20 GiB - exact data boundary; tests vendor present with model null
{
logicalname: '/dev/sdg',
vendor: 'PNY',
model: null,
partitions: [
{
logicalname: '/dev/sdg1',
label: null,
capacity: 20 * GiB,
used: null,
startOs: {},
guid: null,
},
],
capacity: 20 * GiB,
guid: null,
},
// 30 GiB - OK for OS or data alone, too small for both (< 38 GiB)
{
logicalname: '/dev/sdh',
vendor: 'SanDisk',
model: 'Ultra',
partitions: [
{
logicalname: '/dev/sdh1',
label: null,
capacity: 30 * GiB,
used: null,
startOs: {},
guid: null,
},
],
capacity: 30 * GiB,
guid: null,
},
// 30 GiB with existing StartOS data - tests preserve/overwrite + capacity constraint
{
logicalname: '/dev/sdi',
vendor: 'Kingston',
model: 'A400',
partitions: [
{
logicalname: '/dev/sdi1',
label: null,
capacity: 30 * GiB,
used: null,
startOs: {
'small-server-id': {
hostname: 'small-server',
version: '0.3.6',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: 'small-existing-guid',
},
],
capacity: 30 * GiB,
guid: 'small-existing-guid',
},
// 500 GB - large, always OK
{
logicalname: '/dev/sda',
vendor: 'Samsung',
@@ -209,6 +320,7 @@ const MOCK_DISKS: DiskInfo[] = [
capacity: 500000000000,
guid: null,
},
// 1 TB with existing StartOS data
{
logicalname: '/dev/sdb',
vendor: 'Crucial',
@@ -235,6 +347,7 @@ const MOCK_DISKS: DiskInfo[] = [
capacity: 1000000000000,
guid: 'existing-guid',
},
// 2 TB
{
logicalname: '/dev/sdc',
vendor: 'WD',

View File

@@ -1,4 +1,5 @@
import { inject, Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { T } from '@start9labs/start-sdk'
import { ApiService } from './api.service'
@@ -29,6 +30,7 @@ export type RecoverySource =
})
export class StateService {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
// Determined at app init
kiosk = false
@@ -45,6 +47,23 @@ export class StateService {
setupType?: SetupType
recoverySource?: RecoverySource
/**
* Navigate to the appropriate step after language/keyboard selection.
* Keyboard selection is only needed in kiosk mode.
*/
async navigateAfterLocale(): Promise<void> {
if (this.dataDriveGuid) {
if (this.attach) {
this.setupType = 'attach'
await this.router.navigate(['/password'])
} else {
await this.router.navigate(['/home'])
}
} else {
await this.router.navigate(['/drives'])
}
}
/**
* Called for attach flow (existing data drive)
*/

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M22.7 17.21h-3.83v-1.975c0-1.572-1.3-2.86-2.86-2.86s-2.86 1.3-2.86 2.86v1.975H9.33v-1.975c0-3.708 3.023-6.7 6.7-6.7 3.708 0 6.7 3.023 6.7 6.7z" fill="#ffa400"/><path d="M24.282 17.21H7.758a1.27 1.27 0 0 0-1.29 1.29V30.7A1.27 1.27 0 0 0 7.758 32h16.524a1.27 1.27 0 0 0 1.29-1.29V18.5c-.04-.725-.605-1.3-1.3-1.3zm-7.456 8.02v1.652c0 .443-.363.846-.846.846-.443 0-.846-.363-.846-.846V25.23c-.524-.282-.846-.846-.846-1.49 0-.927.766-1.693 1.693-1.693s1.693.766 1.693 1.693c.04.645-.322 1.21-.846 1.49z" fill="#003a70"/><path d="M6.066 15.395h-4a1.17 1.17 0 0 1-1.169-1.169 1.17 1.17 0 0 1 1.169-1.169h4a1.17 1.17 0 0 1 1.169 1.169 1.17 1.17 0 0 1-1.169 1.169zm2.82-6.287a1.03 1.03 0 0 1-.725-.282l-3.144-2.58c-.484-.403-.564-1.128-.16-1.652.403-.484 1.128-.564 1.652-.16l3.144 2.58c.484.403.564 1.128.16 1.652-.282.282-.605.443-.927.443zm7.134-2.74a1.17 1.17 0 0 1-1.169-1.169V1.17A1.17 1.17 0 0 1 16.02 0a1.17 1.17 0 0 1 1.169 1.169V5.2a1.17 1.17 0 0 1-1.169 1.169zm7.093 2.74c-.322 0-.685-.16-.887-.443-.403-.484-.322-1.25.16-1.652l3.144-2.58c.484-.403 1.25-.322 1.652.16s.322 1.25-.16 1.652l-3.144 2.58a1.13 1.13 0 0 1-.766.282zm6.81 6.287h-4.03a1.17 1.17 0 0 1-1.169-1.169 1.17 1.17 0 0 1 1.169-1.169h4.03a1.17 1.17 0 0 1 1.169 1.169 1.17 1.17 0 0 1-1.169 1.169z" fill="#ffa400"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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

@@ -360,7 +360,6 @@ export default {
377: 'StartOS-Sicherungen erkannt',
378: 'Keine StartOS-Sicherungen erkannt',
379: 'StartOS-Version',
381: 'SMTP-Zugangsdaten',
382: 'Test-E-Mail senden',
383: 'Senden',
384: 'E-Mail wird gesendet',
@@ -644,7 +643,6 @@ export default {
706: 'Beibehalten',
707: 'Überschreiben',
708: 'Entsperren',
709: 'Laufwerk',
710: 'Übertragen',
711: 'Die Liste ist leer',
712: 'Jetzt neu starten',
@@ -659,8 +657,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)',
@@ -669,7 +665,6 @@ export default {
731: 'Öffentliche Domain',
732: 'Private Domain',
733: 'Ausblenden',
734: 'Standard ausgehend',
735: 'Zertifikat',
736: 'Selbstsigniert',
737: 'Portweiterleitung',
@@ -704,4 +699,14 @@ export default {
774: 'Der Portstatus kann nicht ermittelt werden, solange der Dienst nicht läuft',
775: 'Diese Adresse funktioniert nicht aus Ihrem lokalen Netzwerk aufgrund einer Router-Hairpinning-Einschränkung',
776: 'Aktion nicht gefunden',
777: 'Diese Domain wird auch gelten für',
778: 'Plugin',
779: 'Öffentlich',
780: 'Privat',
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

@@ -359,7 +359,6 @@ export const ENGLISH: Record<string, number> = {
'StartOS backups detected': 377,
'No StartOS backups detected': 378,
'StartOS Version': 379,
'SMTP Credentials': 381,
'Send test email': 382,
'Send': 383,
'Sending email': 384,
@@ -644,7 +643,6 @@ export const ENGLISH: Record<string, number> = {
'Preserve': 706,
'Overwrite': 707,
'Unlock': 708,
'Drive': 709, // the noun, a storage device
'Transfer': 710, // the verb
'The list is empty': 711,
'Restart now': 712,
@@ -659,8 +657,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,
@@ -669,7 +665,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,
@@ -704,4 +699,14 @@ export const ENGLISH: Record<string, number> = {
'Port status cannot be determined while service is not running': 774,
'This address will not work from your local network due to a router hairpinning limitation': 775,
'Action not found': 776,
'This domain will also apply to': 777,
'Plugin': 778,
'Public': 779, // as in, publicly accessible
'Private': 780, // as in, privately accessible
'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

@@ -360,7 +360,6 @@ export default {
377: 'Copias de seguridad de StartOS detectadas',
378: 'No se detectaron copias de seguridad de StartOS',
379: 'Versión de StartOS',
381: 'Credenciales SMTP',
382: 'Enviar correo de prueba',
383: 'Enviar',
384: 'Enviando correo',
@@ -644,7 +643,6 @@ export default {
706: 'Conservar',
707: 'Sobrescribir',
708: 'Desbloquear',
709: 'Unidad',
710: 'Transferir',
711: 'La lista está vacía',
712: 'Reiniciar ahora',
@@ -659,8 +657,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)',
@@ -669,7 +665,6 @@ export default {
731: 'Dominio público',
732: 'Dominio privado',
733: 'Ocultar',
734: 'saliente predeterminado',
735: 'Certificado',
736: 'Autofirmado',
737: 'Reenvío de puertos',
@@ -704,4 +699,14 @@ export default {
774: 'El estado del puerto no se puede determinar mientras el servicio no está en ejecución',
775: 'Esta dirección no funcionará desde tu red local debido a una limitación de hairpinning del router',
776: 'Acción no encontrada',
777: 'Este dominio también se aplicará a',
778: 'Plugin',
779: 'Público',
780: 'Privado',
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

@@ -360,7 +360,6 @@ export default {
377: 'Sauvegardes StartOS détectées',
378: 'Aucune sauvegarde StartOS détectée',
379: 'Version de StartOS',
381: 'Identifiants SMTP',
382: 'Envoyer un email de test',
383: 'Envoyer',
384: 'Envoi de lemail',
@@ -644,7 +643,6 @@ export default {
706: 'Conserver',
707: 'Écraser',
708: 'Déverrouiller',
709: 'Disque',
710: 'Transférer',
711: 'La liste est vide',
712: 'Redémarrer maintenant',
@@ -659,8 +657,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)',
@@ -669,7 +665,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',
@@ -704,4 +699,14 @@ export default {
774: "L'état du port ne peut pas être déterminé tant que le service n'est pas en cours d'exécution",
775: "Cette adresse ne fonctionnera pas depuis votre réseau local en raison d'une limitation de hairpinning du routeur",
776: 'Action introuvable',
777: "Ce domaine s'appliquera également à",
778: 'Plugin',
779: 'Public',
780: 'Privé',
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

@@ -360,7 +360,6 @@ export default {
377: 'Wykryto kopie zapasowe StartOS',
378: 'Nie wykryto kopii zapasowych StartOS',
379: 'Wersja StartOS',
381: 'Dane logowania SMTP',
382: 'Wyślij e-mail testowy',
383: 'Wyślij',
384: 'Wysyłanie e-maila',
@@ -644,7 +643,6 @@ export default {
706: 'Zachowaj',
707: 'Nadpisz',
708: 'Odblokuj',
709: 'Dysk',
710: 'Przenieś',
711: 'Lista jest pusta',
712: 'Uruchom ponownie teraz',
@@ -659,8 +657,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)',
@@ -669,7 +665,6 @@ export default {
731: 'Domena publiczna',
732: 'Domena prywatna',
733: 'Ukryj',
734: 'domyślne wychodzące',
735: 'Certyfikat',
736: 'Samopodpisany',
737: 'Przekierowanie portów',
@@ -704,4 +699,14 @@ export default {
774: 'Status portu nie może być określony, gdy usługa nie jest uruchomiona',
775: 'Ten adres nie będzie działać z Twojej sieci lokalnej z powodu ograniczenia hairpinning routera',
776: 'Nie znaleziono akcji',
777: 'Ta domena będzie również dotyczyć',
778: 'Wtyczka',
779: 'Publiczny',
780: 'Prywatny',
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

@@ -31,6 +31,7 @@ export interface FormContext<T> {
buttons: ActionButton<T>[]
value?: T
operations?: Operation[]
note?: string
}
@Component({
@@ -43,6 +44,9 @@ export interface FormContext<T> {
(tuiValueChanges)="markAsDirty()"
>
<form-group [spec]="spec" />
@if (note) {
<p class="note">{{ note }}</p>
}
<footer>
<ng-content />
@for (button of buttons; track $index) {
@@ -70,6 +74,12 @@ export interface FormContext<T> {
</form>
`,
styles: `
.note {
color: var(--tui-text-secondary);
font: var(--tui-font-text-s);
margin-top: 1rem;
}
footer {
position: sticky;
bottom: 0;
@@ -106,6 +116,7 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
@Input() buttons = this.context?.data.buttons || []
@Input() operations = this.context?.data.operations || []
@Input() value?: T = this.context?.data.value
@Input() note = this.context?.data.note || ''
form = new FormGroup({})

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

@@ -30,19 +30,6 @@ import { DomainHealthService } from './domain-health.service'
selector: 'td[actions]',
template: `
<div class="desktop">
@if (address().ui) {
<a
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[attr.href]="address().enabled ? address().url : null"
[class.disabled]="!address().enabled"
>
{{ 'Open UI' | i18n }}
</a>
}
@if (address().deletable) {
<button
tuiIconButton
@@ -87,6 +74,19 @@ import { DomainHealthService } from './domain-health.service'
{{ 'Address Requirements' | i18n }}
</button>
}
@if (address().ui) {
<a
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[attr.href]="address().enabled ? address().url : null"
[class.disabled]="!address().enabled"
>
{{ 'Open UI' | i18n }}
</a>
}
<button
tuiIconButton
appearance="flat-grayscale"

View File

@@ -37,7 +37,7 @@ import { InterfaceAddressItemComponent } from './item.component'
selector: 'section[gatewayGroup]',
template: `
<header>
{{ gatewayGroup().gatewayName }}
{{ 'Gateway' | i18n }}: {{ gatewayGroup().gatewayName }}
<button
tuiDropdown
tuiButton
@@ -57,7 +57,14 @@ import { InterfaceAddressItemComponent } from './item.component'
</button>
</header>
<table
[appTable]="['Enabled', 'Type', 'Certificate Authority', 'URL', null]"
[appTable]="[
null,
'Access',
'Type',
'Certificate Authority',
'URL',
null,
]"
>
@for (address of gatewayGroup().addresses; track $index) {
<tr
@@ -69,7 +76,7 @@ import { InterfaceAddressItemComponent } from './item.component'
></tr>
} @empty {
<tr>
<td colspan="5">
<td colspan="6">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
@@ -132,6 +139,7 @@ export class InterfaceAddressesComponent {
}),
}),
),
note: this.getSharedHostNote(),
buttons: [
{
text: this.i18n.transform('Save')!,
@@ -190,6 +198,7 @@ export class InterfaceAddressesComponent {
size: 's',
data: {
spec: await configBuilderToSpec(addSpec),
note: this.getSharedHostNote(),
buttons: [
{
text: this.i18n.transform('Save')!,
@@ -207,18 +216,22 @@ export class InterfaceAddressesComponent {
const loader = this.loader.open('Saving').subscribe()
try {
let configured: boolean
if (this.packageId()) {
await this.api.pkgAddPrivateDomain({
configured = await this.api.pkgAddPrivateDomain({
fqdn,
gateway: gatewayId,
package: this.packageId(),
host: iface?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPrivateDomain({ fqdn, gateway: gatewayId })
configured = await this.api.osUiAddPrivateDomain({
fqdn,
gateway: gatewayId,
})
}
await this.domainHealth.checkPrivateDomain(gatewayId)
await this.domainHealth.checkPrivateDomain(gatewayId, configured)
return true
} catch (e: any) {
@@ -229,6 +242,13 @@ export class InterfaceAddressesComponent {
}
}
private getSharedHostNote(): string {
const names = this.value()?.sharedHostNames
if (!names?.length) return ''
return `${this.i18n.transform('This domain will also apply to')} ${names.join(', ')}`
}
private async savePublicDomain(
fqdn: string,
authority?: 'local' | string,
@@ -241,26 +261,22 @@ export class InterfaceAddressesComponent {
fqdn,
gateway: gatewayId,
acme: !authority || authority === 'local' ? null : authority,
internalPort: iface?.addressInfo.internalPort || 80,
}
try {
let res
if (this.packageId()) {
await this.api.pkgAddPublicDomain({
res = await this.api.pkgAddPublicDomain({
...params,
package: this.packageId(),
host: iface?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPublicDomain(params)
res = await this.api.osUiAddPublicDomain(params)
}
const port = this.gatewayGroup().addresses.find(
a => a.access === 'public' && a.hostnameInfo.port !== null,
)?.hostnameInfo.port
if (port !== undefined && port !== null) {
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, port)
}
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, res)
return true
} catch (e: any) {

View File

@@ -19,21 +19,34 @@ export class DomainHealthService {
async checkPublicDomain(
fqdn: string,
gatewayId: string,
port: number,
portOrRes: number | T.AddPublicDomainRes,
): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
const [dnsPass, portResult] = await Promise.all([
this.api
.queryDns({ fqdn })
.then(ip => ip === gateway.ipInfo.wanIp)
.catch(() => false),
this.api
.checkPort({ gateway: gatewayId, port })
.catch((): null => null),
])
let dnsPass: boolean
let port: number
let portResult: T.CheckPortRes | null
if (typeof portOrRes === 'number') {
port = portOrRes
const [dns, portRes] = await Promise.all([
this.api
.queryDns({ fqdn })
.then(ip => ip === gateway.ipInfo.wanIp)
.catch(() => false),
this.api
.checkPort({ gateway: gatewayId, port: portOrRes })
.catch((): null => null),
])
dnsPass = dns
portResult = portRes
} else {
dnsPass = portOrRes.dns === gateway.ipInfo.wanIp
port = portOrRes.port.port
portResult = portOrRes.port
}
const portOk =
!!portResult?.openInternally &&
@@ -55,14 +68,17 @@ export class DomainHealthService {
}
}
async checkPrivateDomain(gatewayId: string): Promise<void> {
async checkPrivateDomain(
gatewayId: string,
prefetchedConfigured?: boolean,
): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
const configured = await this.api
.checkDns({ gateway: gatewayId })
.catch(() => false)
const configured =
prefetchedConfigured ??
(await this.api.checkDns({ gateway: gatewayId }).catch(() => false))
if (!configured) {
setTimeout(
@@ -150,7 +166,10 @@ export class DomainHealthService {
fqdn: string,
gateway: DnsGateway,
port: number,
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null },
initialResults?: {
dnsPass: boolean
portResult: T.CheckPortRes | null
},
) {
this.dialog
.openComponent(DOMAIN_VALIDATION, {

View File

@@ -10,7 +10,7 @@ import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiObfuscatePipe } from '@taiga-ui/cdk'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { FormsModule } from '@angular/forms'
import { TuiSwitch } from '@taiga-ui/kit'
import { TuiBadge, TuiSwitch } from '@taiga-ui/kit'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GatewayAddress, MappedServiceInterface } from '../interface.service'
import { AddressActionsComponent } from './actions.component'
@@ -36,22 +36,51 @@ import { DomainHealthService } from './domain-health.service'
(ngModelChange)="onToggleEnabled()"
/>
</td>
<td class="type">
<td class="access">
<tui-icon
[icon]="address.access === 'public' ? '@tui.globe' : '@tui.house'"
/>
{{ address.type }}
<span>
{{ (address.access === 'public' ? 'Public' : 'Local') | i18n }}
</span>
</td>
<td class="type">
<tui-badge
size="s"
[appearance]="typeAppearance(address.hostnameInfo.metadata.kind)"
>
{{ address.type }}
</tui-badge>
</td>
<td>
{{ address.certificate }}
<div class="cert">
@if (address.certificate === 'Root CA') {
<img src="assets/icons/favicon.svg" alt="" class="cert-icon" />
} @else if (address.certificate.startsWith("Let's Encrypt")) {
<img src="assets/icons/letsencrypt.svg" alt="" class="cert-icon" />
} @else if (
address.certificate !== '-' && address.certificate !== 'Self signed'
) {
<tui-icon icon="@tui.shield" class="cert-icon" />
}
{{ address.certificate }}
</div>
</td>
<td>
<div class="url">
<span
[title]="address.masked && currentlyMasked() ? '' : address.url"
>
{{ address.url | tuiObfuscate: recipe() }}
</span>
@if (address.masked && currentlyMasked()) {
<span>{{ address.url | tuiObfuscate: 'mask' }}</span>
} @else {
<span [title]="address.url">
@if (urlParts(); as parts) {
{{ parts.prefix }}
<b>{{ parts.hostname }}</b>
{{ parts.suffix }}
} @else {
{{ address.url }}
}
</span>
}
@if (address.masked) {
<button
tuiIconButton
@@ -81,12 +110,28 @@ import { DomainHealthService } from './domain-health.service'
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
}
.type tui-icon {
.access tui-icon {
font-size: 1.3rem;
margin-right: 0.7rem;
vertical-align: middle;
}
.cert {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cert-icon {
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
}
tui-icon.cert-icon {
font-size: 1.25rem;
}
.url {
display: flex;
align-items: center;
@@ -104,6 +149,7 @@ import { DomainHealthService } from './domain-health.service'
:host-context(tui-root._mobile) {
padding-inline-start: 0.75rem !important;
row-gap: 0.25rem;
&::before {
content: '';
@@ -129,18 +175,32 @@ import { DomainHealthService } from './domain-health.service'
display: none;
}
td:nth-child(2) {
.access {
padding-right: 0;
font: var(--tui-font-text-m);
font-weight: bold;
tui-icon {
display: none;
}
}
.type {
font: var(--tui-font-text-m);
font-weight: bold;
color: var(--tui-text-primary);
padding-inline-end: 0.5rem;
}
td:nth-child(3) {
td:nth-child(4) {
grid-area: 2 / 1 / 2 / 3;
.cert-icon {
display: none;
}
}
td:nth-child(4) {
td:nth-child(5) {
grid-area: 3 / 1 / 3 / 3;
}
@@ -154,6 +214,7 @@ import { DomainHealthService } from './domain-health.service'
imports: [
i18nPipe,
AddressActionsComponent,
TuiBadge,
TuiButton,
TuiIcon,
TuiObfuscatePipe,
@@ -180,6 +241,33 @@ export class InterfaceAddressItemComponent {
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
)
readonly urlParts = computed(() => {
const { url, hostnameInfo } = this.address()
const idx = url.indexOf(hostnameInfo.hostname)
if (idx === -1) return null
return {
prefix: url.slice(0, idx),
hostname: hostnameInfo.hostname,
suffix: url.slice(idx + hostnameInfo.hostname.length),
}
})
typeAppearance(kind: string): string {
switch (kind) {
case 'public-domain':
case 'private-domain':
return 'info'
case 'mdns':
return 'positive'
case 'ipv4':
return 'warning'
case 'ipv6':
return 'neutral'
default:
return 'neutral'
}
}
async onToggleEnabled() {
const addr = this.address()
const iface = this.value()

View File

@@ -32,7 +32,7 @@ import {
@if (pluginGroup().pluginPkgInfo; as pkgInfo) {
<img [src]="pkgInfo.icon" alt="" class="plugin-icon" />
}
{{ pluginGroup().pluginName }}
{{ 'Plugin' | i18n }}: {{ pluginGroup().pluginName }}
@if (pluginGroup().tableAction; as action) {
<button
tuiButton

View File

@@ -81,7 +81,7 @@ function getAddressType(h: T.HostnameInfo): string {
return 'IPv6'
case 'public-domain':
case 'private-domain':
return h.hostname
return 'Domain'
case 'mdns':
return 'mDNS'
case 'plugin':
@@ -116,7 +116,12 @@ export class InterfaceService {
gatewayMap.set(gateway.id, gateway)
}
for (const h of addr.available) {
const available =
this.config.accessType === 'localhost'
? addr.available
: utils.filterNonLocal(addr.available)
for (const h of available) {
const gatewayIds = getGatewayIds(h)
for (const gid of gatewayIds) {
const list = groupMap.get(gid)
@@ -337,4 +342,5 @@ export type MappedServiceInterface = T.ServiceInterface & {
gatewayGroups: GatewayAddressGroup[]
pluginGroups: PluginAddressGroup[]
addSsl: boolean
sharedHostNames: string[]
}

View File

@@ -28,7 +28,7 @@ interface ActionItem {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiTitle],
host: {
'[disabled]': '!!disabled() || inactive()',
'[attr.disabled]': '(!!disabled() || inactive()) || null',
},
})
export class ServiceActionComponent {

View File

@@ -23,7 +23,6 @@ import {
ALLOWED_STATUSES,
getInstalledBaseStatus,
INACTIVE_STATUSES,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { getManifest } from 'src/app/utils/get-package-data'
@@ -153,7 +152,7 @@ export class ServiceTaskComponent {
const action = pkg.actions[this.task().actionId]
if (!action) return this.i18n.transform('Action not found')!
const status = renderPkgStatus(pkg).primary
const status = getInstalledBaseStatus(pkg.statusInfo)
if (INACTIVE_STATUSES.includes(status)) return status as string

View File

@@ -24,9 +24,9 @@ import { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionComponent } from '../components/action.component'
import {
ALLOWED_STATUSES,
BaseStatus,
getInstalledBaseStatus,
INACTIVE_STATUSES,
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'
@@ -108,7 +108,7 @@ export default class ServiceActionsRoute {
const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
? 'Other'
: 'General'
const status = renderPkgStatus(pkg).primary
const status = getInstalledBaseStatus(pkg.statusInfo)
return {
status,
icon: pkg.icon,
@@ -187,7 +187,7 @@ export default class ServiceActionsRoute {
}
handle(
status: PrimaryStatus,
status: BaseStatus,
icon: string,
{ id, title }: T.Manifest,
action: T.ActionMetadata & { id: string },

View File

@@ -125,6 +125,10 @@ export default class ServiceInterfaceRoute {
const binding = host.bindings[port]
const gateways = this.gatewayService.gateways() || []
const sharedHostNames = Object.values(serviceInterfaces)
.filter(si => si.addressInfo.hostId === key && si.id !== iFace.id)
.map(si => si.name)
return {
...iFace,
gatewayGroups: this.interfaceService.getGatewayGroups(
@@ -132,8 +136,13 @@ export default class ServiceInterfaceRoute {
host,
gateways,
),
pluginGroups: this.interfaceService.getPluginGroups(iFace, host, this.allPackageData()),
pluginGroups: this.interfaceService.getPluginGroups(
iFace,
host,
this.allPackageData(),
),
addSsl: !!binding?.options.addSsl,
sharedHostNames,
}
})

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

@@ -1,258 +0,0 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nKey,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { inputSpec } from '@start9labs/start-sdk'
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { Subscription, switchMap, tap } from 'rxjs'
import { FormGroupComponent } from 'src/app/routes/portal/components/form/containers/group.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormService } from 'src/app/services/form.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
const PROVIDER_HINTS: Record<string, string> = {
gmail:
'Requires an App Password. Enable 2FA in your Google account, then generate an App Password.',
ses: 'Use SMTP credentials (not IAM credentials). Update the host to match your SES region.',
sendgrid:
"Username is 'apikey' (literal). Password is your SendGrid API key.",
mailgun: 'Use SMTP credentials from your Mailgun domain settings.',
protonmail:
'Requires a Proton for Business account. Use your Proton email as username.',
}
function detectProviderKey(host: string | undefined): string {
if (!host) return 'other'
const providers: Record<string, string> = {
'smtp.gmail.com': 'gmail',
'smtp.sendgrid.net': 'sendgrid',
'smtp.mailgun.org': 'mailgun',
'smtp.protonmail.ch': 'protonmail',
}
for (const [h, key] of Object.entries(providers)) {
if (host === h) return key
}
if (host.endsWith('.amazonaws.com')) return 'ses'
return 'other'
}
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }}
</a>
{{ 'SMTP' | i18n }}
</ng-container>
@if (form$ | async; as form) {
<form [formGroup]="form">
<header tuiHeader="body-l">
<h3 tuiTitle>
<b>
{{ 'SMTP Credentials' | i18n }}
<a
tuiIconButton
size="xs"
docsLink
path="/start-os/user-manual/smtp.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
</b>
</h3>
</header>
@if (spec | async; as resolved) {
<form-group [spec]="resolved" />
}
@if (providerHint()) {
<p class="provider-hint">{{ providerHint() }}</p>
}
<footer>
@if (isSaved) {
<button
tuiButton
size="l"
appearance="secondary-destructive"
(click)="save(null)"
>
{{ 'Delete' | i18n }}
</button>
}
<button
tuiButton
size="l"
[disabled]="form.invalid || form.pristine"
(click)="save(form.value)"
>
{{ 'Save' | i18n }}
</button>
</footer>
</form>
<form>
<header tuiHeader="body-l">
<h3 tuiTitle>
<b>{{ 'Send test email' | i18n }}</b>
</h3>
</header>
<tui-textfield>
<label tuiLabel>Name Lastname &lt;email&#64;example.com&gt;</label>
<input
tuiTextfield
inputmode="email"
[(ngModel)]="testAddress"
[ngModelOptions]="{ standalone: true }"
/>
</tui-textfield>
<footer>
<button
tuiButton
size="l"
[disabled]="!testAddress || form.invalid"
(click)="sendTestEmail(form.value)"
>
{{ 'Send' | i18n }}
</button>
</footer>
</form>
}
`,
styles: `
:host {
max-width: 36rem;
}
form header,
form footer {
margin: 1rem 0;
display: flex;
gap: 1rem;
}
footer {
justify-content: flex-end;
}
.provider-hint {
margin: 0.5rem 0 0;
font-size: 0.85rem;
opacity: 0.7;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
FormGroupComponent,
TuiButton,
TuiTextfield,
TuiHeader,
TuiTitle,
RouterLink,
TitleDirective,
i18nPipe,
DocsLinkDirective,
],
})
export default class SystemEmailComponent {
private readonly dialog = inject(DialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formService = inject(FormService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
readonly providerHint = signal('')
private providerSub: Subscription | null = null
testAddress = ''
isSaved = false
readonly spec = configBuilderToSpec(inputSpec.constants.systemSmtpSpec)
readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
tap(value => {
this.isSaved = !!value
}),
switchMap(async value => {
const spec = await this.spec
const formData = value
? { provider: { selection: detectProviderKey(value.host), value } }
: undefined
const form = this.formService.createForm(spec, formData)
// Watch provider selection for hints
this.providerSub?.unsubscribe()
const selectionCtrl = form.get('provider.selection')
if (selectionCtrl) {
this.providerHint.set(PROVIDER_HINTS[selectionCtrl.value] || '')
this.providerSub = selectionCtrl.valueChanges.subscribe(key => {
this.providerHint.set(PROVIDER_HINTS[key] || '')
})
}
return form
}),
)
async save(formValue: Record<string, any> | null): Promise<void> {
const loader = this.loader.open('Saving').subscribe()
try {
if (formValue) {
await this.api.setSmtp(formValue['provider'].value)
this.isSaved = true
} else {
await this.api.clearSmtp({})
this.isSaved = false
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async sendTestEmail(formValue: Record<string, any>) {
const smtpValue = formValue['provider'].value
const loader = this.loader.open('Sending email').subscribe()
const success =
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
try {
await this.api.testSmtp({
...smtpValue,
password: smtpValue.password || '',
to: this.testAddress,
})
this.dialog
.openAlert(success, { label: 'Success', size: 's' })
.subscribe()
this.testAddress = ''
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

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,48 @@ 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()
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 +248,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 +268,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

@@ -0,0 +1,280 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { FormControl, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nKey,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { inputSpec, ISB, utils } from '@start9labs/start-sdk'
import { TuiButton, TuiError, TuiTextfield, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { switchMap } from 'rxjs'
import { FormGroupComponent } from 'src/app/routes/portal/components/form/containers/group.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormService } from 'src/app/services/form.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
function detectProviderKey(host: string | undefined): string {
if (!host) return 'other'
const providers: Record<string, string> = {
'smtp.gmail.com': 'gmail',
'smtp.sendgrid.net': 'sendgrid',
'smtp.mailgun.org': 'mailgun',
'smtp.protonmail.ch': 'protonmail',
}
for (const [h, key] of Object.entries(providers)) {
if (host === h) return key
}
if (host.endsWith('.amazonaws.com')) return 'ses'
return 'other'
}
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }}
</a>
SMTP
</ng-container>
@if (form$ | async; as data) {
<form [formGroup]="data.form">
<header tuiHeader="body-l">
<h3 tuiTitle>
<b>
SMTP
<a
tuiIconButton
size="xs"
docsLink
path="/start-os/user-manual/smtp.html"
appearance="icon"
iconStart="@tui.book-open-text"
>
{{ 'Documentation' | i18n }}
</a>
</b>
</h3>
</header>
<form-group [spec]="data.spec" />
<footer>
@if (!data.form.pristine) {
<button
tuiButton
size="l"
appearance="secondary"
(click)="cancel(data)"
>
{{ 'Cancel' | i18n }}
</button>
}
<button
tuiButton
size="l"
[disabled]="data.form.invalid || data.form.pristine"
(click)="save(data.form.value)"
>
{{ 'Save' | i18n }}
</button>
</footer>
</form>
@if (data.form.value.smtp?.selection === 'enabled') {
<form>
<header tuiHeader="body-l">
<h3 tuiTitle>
<b>{{ 'Send test email' | i18n }}</b>
</h3>
</header>
<tui-textfield>
<label tuiLabel>email&#64;example.com</label>
<input
tuiTextfield
inputmode="email"
[formControl]="testEmailControl"
/>
</tui-textfield>
<tui-error
[error]="
!testEmailControl.pristine && isEmailInvalid
? ('Must be a valid email address' | i18n)
: null
"
/>
<footer>
<button
tuiButton
size="l"
[disabled]="
!testEmailControl.value || isEmailInvalid || data.form.invalid
"
(click)="sendTestEmail(data.form.value)"
>
{{ 'Send' | i18n }}
</button>
</footer>
</form>
}
}
`,
styles: `
:host {
max-width: 36rem;
}
form header,
form footer {
margin: 1rem 0;
display: flex;
gap: 1rem;
}
footer {
justify-content: flex-end;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ReactiveFormsModule,
FormGroupComponent,
TuiButton,
TuiError,
TuiTextfield,
TuiHeader,
TuiTitle,
RouterLink,
TitleDirective,
i18nPipe,
DocsLinkDirective,
],
})
export default class SystemEmailComponent {
private readonly dialog = inject(DialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formService = inject(FormService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
private readonly emailRegex = new RegExp(utils.Patterns.email.regex)
readonly testEmailControl = new FormControl('')
get isEmailInvalid(): boolean {
const value = this.testEmailControl.value
return !!value && !this.emailRegex.test(value)
}
private readonly smtpSpec = ISB.InputSpec.of({
smtp: ISB.Value.union({
name: this.i18n.transform('SMTP'),
default: 'disabled',
variants: ISB.Variants.of({
disabled: {
name: this.i18n.transform('Disabled'),
spec: ISB.InputSpec.of({}),
},
enabled: {
name: this.i18n.transform('Enabled'),
spec: inputSpec.constants.systemSmtpSpec,
},
}),
}),
})
readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
switchMap(async value => {
const spec = await configBuilderToSpec(this.smtpSpec)
const formData = value
? {
smtp: {
selection: 'enabled' as const,
value: {
provider: {
selection: detectProviderKey(value.host),
value: {
host: value.host,
security: {
selection: value.security,
value: { port: String(value.port) },
},
from: value.from,
username: value.username,
password: value.password,
},
},
},
},
}
: undefined
const form = this.formService.createForm(spec, formData)
return { form, spec, formData }
}),
)
private getSmtpValue(formValue: Record<string, any>) {
const { security, ...rest } = formValue['smtp'].value.provider.value
return {
...rest,
security: security.selection,
port: Number(security.value.port),
}
}
async save(formValue: Record<string, any>): Promise<void> {
const loader = this.loader.open('Saving').subscribe()
try {
if (formValue['smtp'].selection === 'disabled') {
await this.api.clearSmtp({})
} else {
await this.api.setSmtp(this.getSmtpValue(formValue))
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
cancel(data: {
form: ReturnType<FormService['createForm']>
formData: Record<string, any> | undefined
}) {
data.form.reset(data.formData)
}
async sendTestEmail(formValue: Record<string, any>) {
const smtpValue = this.getSmtpValue(formValue)
const address = this.testEmailControl.value!
const loader = this.loader.open('Sending email').subscribe()
const success =
`${this.i18n.transform('A test email has been sent to')} ${address}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
try {
await this.api.testSmtp({
...smtpValue,
password: smtpValue.password || '',
to: address,
})
this.dialog
.openAlert(success, { label: 'Success', size: 's' })
.subscribe()
this.testEmailControl.reset()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

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

@@ -73,9 +73,7 @@ export default class StartOsUiComponent {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly network = toSignal(
this.patch.watch$('serverInfo', 'network'),
)
readonly network = toSignal(this.patch.watch$('serverInfo', 'network'))
readonly allPackageData = toSignal(this.patch.watch$('packageData'))
@@ -98,6 +96,7 @@ export default class StartOsUiComponent {
this.allPackageData(),
),
addSsl: true,
sharedHostNames: [],
}
})
}

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

@@ -28,7 +28,7 @@ export default [
{
path: 'email',
title: titleResolver,
loadComponent: () => import('./routes/email/email.component'),
loadComponent: () => import('./routes/smtp/smtp.component'),
},
{
path: 'backup',

View File

@@ -340,11 +340,13 @@ export abstract class ApiService {
abstract osUiAddPublicDomain(
params: T.AddPublicDomainParams,
): Promise<string | null>
): Promise<T.AddPublicDomainRes>
abstract osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null>
abstract osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null>
abstract osUiAddPrivateDomain(
params: T.AddPrivateDomainParams,
): Promise<boolean>
abstract osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null>
@@ -354,13 +356,15 @@ export abstract class ApiService {
abstract pkgAddPublicDomain(
params: PkgAddPublicDomainReq,
): Promise<string | null>
): Promise<T.AddPublicDomainRes>
abstract pkgRemovePublicDomain(
params: PkgRemovePublicDomainReq,
): Promise<null>
abstract pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null>
abstract pkgAddPrivateDomain(
params: PkgAddPrivateDomainReq,
): Promise<boolean>
abstract pkgRemovePrivateDomain(
params: PkgRemovePrivateDomainReq,

View File

@@ -393,7 +393,7 @@ export class LiveApiService extends ApiService {
// wifi
async enableWifi(params: T.SetWifiEnabledParams): Promise<null> {
return this.rpcRequest({ method: 'wifi.enable', params })
return this.rpcRequest({ method: 'wifi.set-enabled', params })
}
async getWifi(params: {}, timeout?: number): Promise<T.WifiListInfo> {
@@ -630,7 +630,7 @@ export class LiveApiService extends ApiService {
async osUiAddPublicDomain(
params: T.AddPublicDomainParams,
): Promise<string | null> {
): Promise<T.AddPublicDomainRes> {
return this.rpcRequest({
method: 'server.host.address.domain.public.add',
params,
@@ -644,7 +644,9 @@ export class LiveApiService extends ApiService {
})
}
async osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null> {
async osUiAddPrivateDomain(
params: T.AddPrivateDomainParams,
): Promise<boolean> {
return this.rpcRequest({
method: 'server.host.address.domain.private.add',
params,
@@ -669,7 +671,7 @@ export class LiveApiService extends ApiService {
async pkgAddPublicDomain(
params: PkgAddPublicDomainReq,
): Promise<string | null> {
): Promise<T.AddPublicDomainRes> {
return this.rpcRequest({
method: 'package.host.address.domain.public.add',
params,
@@ -683,7 +685,7 @@ export class LiveApiService extends ApiService {
})
}
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null> {
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<boolean> {
return this.rpcRequest({
method: 'package.host.address.domain.private.add',
params,

View File

@@ -1440,7 +1440,7 @@ export class MockApiService extends ApiService {
async osUiAddPublicDomain(
params: T.AddPublicDomainParams,
): Promise<string | null> {
): Promise<T.AddPublicDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
@@ -1465,7 +1465,16 @@ export class MockApiService extends ApiService {
]
this.mockRevision(patch)
return null
return {
dns: null,
port: {
ip: '0.0.0.0',
port: 443,
openExternally: false,
openInternally: false,
hairpinning: false,
},
}
}
async osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null> {
@@ -1482,7 +1491,9 @@ export class MockApiService extends ApiService {
return null
}
async osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null> {
async osUiAddPrivateDomain(
params: T.AddPrivateDomainParams,
): Promise<boolean> {
await pauseFor(2000)
const patch: Operation<any>[] = [
@@ -1505,7 +1516,7 @@ export class MockApiService extends ApiService {
]
this.mockRevision(patch)
return null
return false
}
async osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null> {
@@ -1535,7 +1546,7 @@ export class MockApiService extends ApiService {
async pkgAddPublicDomain(
params: PkgAddPublicDomainReq,
): Promise<string | null> {
): Promise<T.AddPublicDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
@@ -1560,7 +1571,16 @@ export class MockApiService extends ApiService {
]
this.mockRevision(patch)
return null
return {
dns: null,
port: {
ip: '0.0.0.0',
port: 443,
openExternally: false,
openInternally: false,
hairpinning: false,
},
}
}
async pkgRemovePublicDomain(params: PkgRemovePublicDomainReq): Promise<null> {
@@ -1577,7 +1597,9 @@ export class MockApiService extends ApiService {
return null
}
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null> {
async pkgAddPrivateDomain(
params: PkgAddPrivateDomainReq,
): Promise<boolean> {
await pauseFor(2000)
const patch: Operation<any>[] = [
@@ -1600,7 +1622,7 @@ export class MockApiService extends ApiService {
]
this.mockRevision(patch)
return null
return false
}
async pkgRemovePrivateDomain(

View File

@@ -212,6 +212,7 @@ export const mockPatchData: DataModel = {
},
},
},
passthroughs: [],
defaultOutbound: 'eth0',
dns: {
dhcpServers: ['1.1.1.1', '8.8.8.8'],
@@ -651,7 +652,7 @@ export const mockPatchData: DataModel = {
publicDomains: {
'bitcoin.example.com': {
gateway: 'eth0',
acme: null,
acme: 'https://acme-v02.api.letsencrypt.org/directory',
},
},
privateDomains: {

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 {