diff --git a/web/package-lock.json b/web/package-lock.json index 8cfdd9e05..27332c024 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -62,6 +62,7 @@ "patch-db-client": "file:../patch-db/client", "pbkdf2": "^3.1.2", "rxjs": "^7.8.2", + "tldts": "^7.0.11", "ts-matches": "^6.3.2", "tslib": "^2.8.1", "uuid": "^8.3.2", @@ -12185,6 +12186,24 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.11.tgz", + "integrity": "sha512-7k7JV/LZpGhFUu2t+YDaMZ1wdPPRNpaCYNQ0NQbSLY3Rbgy+XbCdkXyqRiS9TLXiYAsrv0yiA0OvnxmgRFCdNA==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.11.tgz", + "integrity": "sha512-65eeOpBwWBabh0XqT+zB0vEllq/V3XcrF2fhgMXWWFfNw1yxEjeYg9Vv/B/UNozd0CTR/TohO1ubfn6O6mBW3w==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", diff --git a/web/package.json b/web/package.json index 788aeb5d0..901674c38 100644 --- a/web/package.json +++ b/web/package.json @@ -52,6 +52,7 @@ "@taiga-ui/addon-table": "4.47.0", "@taiga-ui/cdk": "4.47.0", "@taiga-ui/core": "4.47.0", + "@taiga-ui/dompurify": "4.1.11", "@taiga-ui/event-plugins": "4.6.0", "@taiga-ui/experimental": "4.47.0", "@taiga-ui/icons": "4.47.0", @@ -59,7 +60,6 @@ "@taiga-ui/layout": "4.47.0", "@taiga-ui/legacy": "4.47.0", "@taiga-ui/polymorpheus": "4.9.0", - "@taiga-ui/dompurify": "4.1.11", "ansi-to-html": "^0.7.2", "base64-js": "^1.5.1", "buffer": "^6.0.3", @@ -68,8 +68,8 @@ "core-js": "^3.42.0", "cron": "^2.2.0", "cronstrue": "^2.21.0", - "dompurify": "^3.1.7", "deep-equality-data-structures": "1.5.1", + "dompurify": "^3.1.7", "fast-json-patch": "^3.1.1", "fuse.js": "^6.4.6", "jose": "^4.9.0", @@ -83,17 +83,18 @@ "patch-db-client": "file:../patch-db/client", "pbkdf2": "^3.1.2", "rxjs": "^7.8.2", + "tldts": "^7.0.11", "ts-matches": "^6.3.2", "tslib": "^2.8.1", "uuid": "^8.3.2", "zone.js": "^0.15.0" }, "devDependencies": { + "@angular-experts/hawkeye": "^1.7.2", "@angular/build": "^20.1.0", "@angular/cli": "^20.1.0", "@angular/compiler-cli": "^20.1.0", "@angular/language-service": "^20.1.0", - "@angular-experts/hawkeye": "^1.7.2", "@types/dompurify": "3.0.5", "@types/estree": "^0.0.51", "@types/js-yaml": "^4.0.5", diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index b6f638dc0..0ec4883d0 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -112,7 +112,7 @@ export default { 109: 'Privat', 110: 'Fügen Sie eine Onion-Adresse hinzu, um dieses Interface anonym im Darknet verfügbar zu machen. Onion-Adressen sind nur über das Tor-Netzwerk erreichbar.', 111: 'Keine Onion-Adressen', - 112: 'Neue Onion-Adresse', + 112: 'Neue onion-adresse', 113: 'Privater Schlüssel (optional)', 114: 'Optional können Sie einen base64-codierten ed25519-Schlüssel angeben, um die Tor V3 (.onion)-Adresse zu generieren. Wenn nicht angegeben, wird ein zufälliger Schlüssel erstellt.', 115: 'Verarbeite 10.000 Logs', @@ -303,7 +303,7 @@ export default { 308: 'Erforderlich, um ein Zertifikat von einer Zertifizierungsstelle zu erhalten', 309: 'Alle umschalten', 310: 'Fertig', - 311: 'Master-Passwort erforderlich', + 311: 'Master-passwort erforderlich', 312: 'Geben Sie Ihr Master-Passwort ein, um diese Sicherung zu verschlüsseln.', 313: 'Master-Passwort', 314: 'Master-Passwort eingeben', @@ -539,6 +539,5 @@ export default { 544: 'Domain bearbeiten', 545: 'Keine Domains', 546: 'Anbieter', - 547: 'DNS anzeigen', - 548: 'DNS testen', + 547: 'DNS verwalten', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index e621ae040..4e7847cba 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -15,7 +15,7 @@ export const ENGLISH = { 'Change Password': 13, 'General Settings': 14, 'Manage your overall setup and preferences': 15, - 'Browser Tab Title': 16, + 'Browser tab title': 16, 'Language': 17, 'Disk Repair': 18, 'Attempt automatic repair': 19, @@ -103,7 +103,7 @@ export const ENGLISH = { 'You have unsaved changes. Are you sure you want to leave?': 101, 'Leave': 102, 'Are you sure?': 103, - 'Select Domain': 104, + 'Select domain': 104, 'Local': 105, 'Local addresses can only be accessed by devices connected to the same LAN as your server, either directly or using a VPN.': 106, 'Learn More': 107, @@ -111,7 +111,7 @@ export const ENGLISH = { 'Private': 109, 'Add an onion address to anonymously expose this interface on the darknet. Onion addresses can only be reached over the Tor network.': 110, 'No onion addresses': 111, - 'New Onion Address': 112, + 'New onion address': 112, 'Private Key (optional)': 113, 'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.': 114, 'Processing 10,000 logs': 115, @@ -297,16 +297,16 @@ export const ENGLISH = { 'Contact': 303, // as in, "contact us" 'Edit': 304, 'Add Certificate Authority': 305, - 'Edit Contact Info': 306, + 'Edit contact info': 306, 'Contact Emails': 307, 'Needed to obtain a certificate from a Certificate Authority': 308, 'Toggle all': 309, 'Done': 310, - 'Master Password Needed': 311, + 'Master password needed': 311, 'Enter your master password to encrypt this backup.': 312, 'Master Password': 313, 'Enter master password': 314, - 'Original Password Needed': 315, + 'Original password needed': 315, 'This backup was created with a different password. Enter the original password that was used to encrypt this backup.': 316, 'Original Password': 317, 'Enter original password': 318, @@ -363,7 +363,7 @@ export const ENGLISH = { 'Ready to restore': 369, 'Local Hostname': 370, 'Created': 371, - 'Password Required': 372, + 'Password required': 372, 'Enter the master password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.': 373, 'Decrypting drive': 374, 'Select services to restore': 375, @@ -395,7 +395,7 @@ export const ENGLISH = { 'Terminate selected': 401, 'Terminating sessions': 402, 'No sessions': 403, - 'Password Needed': 404, + 'Password needed': 404, 'Connected': 405, 'Forget': 406, // as in, delete or remove 'WiFi Credentials': 407, @@ -526,7 +526,7 @@ export const ENGLISH = { 'Finished': 532, // an in, complete 'Gateways': 533, // as in, a device or software that connects two different networks 'Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic.': 534, - 'Add Gateway': 535, // as in, add a new network gateway to StartOS + 'Add gateway': 535, // as in, add a new network gateway to StartOS 'Rename': 536, 'Access': 537, // as in, public or private access, almost "permission" 'Domains': 538, // as in, internet domains @@ -535,9 +535,8 @@ export const ENGLISH = { 'Gateway': 541, // as in, a device or software that connects two different networks 'Default Certificate Authority': 542, 'Certificate Authority': 543, - 'Edit Domain': 544, + 'Edit domain': 544, 'No domains': 545, 'Provider': 546, - 'Show DNS': 547, - 'Test DNS': 548, + 'Manage DNS': 547, } as const diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index 346b656ce..582c7389a 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -112,7 +112,7 @@ export default { 109: 'Privado', 110: 'Agrega una dirección onion para exponer esta interfaz de forma anónima en la darknet. Las direcciones onion solo se pueden acceder a través de la red Tor.', 111: 'Sin direcciones onion', - 112: 'Nueva dirección Onion', + 112: 'Nueva dirección onion', 113: 'Clave privada (opcional)', 114: 'Opcionalmente proporciona una clave privada ed25519 codificada en base64 para generar la dirección Tor V3 (.onion). Si no se proporciona, se generará una clave aleatoria.', 115: 'Procesando 10,000 registros', @@ -294,7 +294,6 @@ export default { 297: 'Se detectó un paquete s9pk de versión 1. Este formato está obsoleto. Puedes instalarlo manualmente con start-cli si es necesario.', 298: 'Archivo de paquete inválido', 299: 'Agregar un dominio a StartOS significa que puedes usarlo y sus subdominios para alojar interfaces de servicios en Internet público.', - 300: 'Ver instrucciones', 303: 'Contacto', 304: 'Editar', @@ -540,6 +539,5 @@ export default { 544: 'Editar dominio', 545: 'Sin dominios', 546: 'Proveedor', - 547: 'Mostrar DNS', - 548: 'Probar DNS', + 547: 'Administrar DNS', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index e9b498a14..f304788c1 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -539,6 +539,5 @@ export default { 544: 'Modifier le domaine', 545: 'Aucun domaine', 546: 'Fournisseur', - 547: 'Afficher le DNS', - 548: 'Tester le DNS', + 547: 'Gérer le DNS', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 121856c07..fdd54bb55 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -112,7 +112,7 @@ export default { 109: 'Prywatny', 110: 'Dodaj adres onion, aby anonimowo udostępnić ten interfejs w sieci Tor. Adresy onion są dostępne tylko przez sieć Tor.', 111: 'Brak adresów onion', - 112: 'Nowy adres Onion', + 112: 'Nowy adres onion', 113: 'Klucz prywatny (opcjonalnie)', 114: 'Opcjonalnie podaj klucz prywatny ed25519 zakodowany w base64, aby wygenerować adres Tor V3 (.onion). Jeśli nie zostanie podany, zostanie wygenerowany i użyty losowy klucz.', 115: 'Przetwarzanie 10 000 logów', @@ -539,6 +539,5 @@ export default { 544: 'Edytuj domenę', 545: 'Brak domen', 546: 'Dostawca', - 547: 'Pokaż DNS', - 548: 'Test DNS', + 547: 'Zarządzaj DNS', } satisfies i18n diff --git a/web/projects/ui/src/app/components/refresh-alert.component.ts b/web/projects/ui/src/app/components/refresh-alert.component.ts index 40a18ff80..8c9307500 100644 --- a/web/projects/ui/src/app/components/refresh-alert.component.ts +++ b/web/projects/ui/src/app/components/refresh-alert.component.ts @@ -12,6 +12,8 @@ import { distinctUntilChanged, map, merge, Subject } from 'rxjs' import { ConfigService } from 'src/app/services/config.service' import { DataModel } from 'src/app/services/patch-db/data-model' +// @TODO translations + @Component({ selector: 'refresh-alert', template: ` diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts index 65ff42b08..810275ca6 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts @@ -221,7 +221,7 @@ export class InterfaceClearnetComponent { host: this.interface.value().addressInfo.hostId, }) } else { - await this.api.serverRemoveDomain(params) + await this.api.osUiRemoveDomain(params) } return true } catch (e: any) { @@ -256,7 +256,7 @@ export class InterfaceClearnetComponent { }) this.formDialog.open>(FormComponent, { - label: 'Select Domain', + label: 'Select domain', data: { spec: await configBuilderToSpec( ISB.InputSpec.of( @@ -292,7 +292,7 @@ export class InterfaceClearnetComponent { host: this.interface.value().addressInfo.hostId, }) } else { - await this.api.serverAddDomain(params) + await this.api.osUiAddDomain(params) } return true } catch (e: any) { diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts index b812470c8..9a13bc631 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts @@ -179,7 +179,7 @@ export class InterfaceTorComponent { async add() { this.formDialog.open>(FormComponent, { - label: 'New Onion Address', + label: 'New onion address', data: { spec: await configBuilderToSpec( ISB.InputSpec.of({ diff --git a/web/projects/ui/src/app/routes/portal/components/tabs.component.ts b/web/projects/ui/src/app/routes/portal/components/tabs.component.ts index 9171b29b0..e03ac3e19 100644 --- a/web/projects/ui/src/app/routes/portal/components/tabs.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/tabs.component.ts @@ -133,7 +133,7 @@ export class TabsComponent { ) more(content: TemplateRef) { - this.dialogs.open(content, { label: 'Start OS' }).subscribe({ + this.dialogs.open(content, { label: 'StartOS' }).subscribe({ complete: () => this.update(), }) } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backup.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backup.component.ts index 52f0cef71..4e692abed 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backup.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backup.component.ts @@ -128,7 +128,7 @@ export class BackupsBackupComponent { this.dialog .openPrompt({ - label: 'Master Password Needed', + label: 'Master password needed', data: { message: 'Enter your master password to encrypt this backup.', label: 'Master Password', @@ -169,7 +169,7 @@ export class BackupsBackupComponent { this.dialog .openPrompt({ - label: 'Original Password Needed', + label: 'Original password needed', data: { message: 'This backup was created with a different password. Enter the original password that was used to encrypt this backup.', diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/restore.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/restore.component.ts index b5ce3a857..90f6498fb 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/restore.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/restore.component.ts @@ -55,7 +55,7 @@ export class BackupRestoreComponent { onClick(serverId: string, { passwordHash }: StartOSDiskInfo) { this.dialog .openPrompt({ - label: 'Password Required', + label: 'Password required', data: { message: 'Enter the master password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.', diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/authority.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/authority.service.ts index 52de892d7..9c8e3fef1 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/authority.service.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/authority.service.ts @@ -117,7 +117,7 @@ export class AuthorityService { }) this.formDialog.open(FormComponent, { - label: 'Edit Contact Info', + label: 'Edit contact info', data: { spec: await configBuilderToSpec(editSpec), buttons: [ diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/item.component.ts index c1aa0ab7b..380b98d79 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/authorities/item.component.ts @@ -19,7 +19,9 @@ import { Authority, AuthorityService } from './authority.service' @if (authority(); as authority) { {{ authority.name }} {{ authority.url || '-' }} - {{ authority.contact ? authority.contact.join(', ') : '-' }} + + {{ authority.contact ? authority.contact.join(', ') : '-' }} + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiButton, i18nPipe, TableComponent], +}) +export class DnsComponent { + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + + readonly context = injectContext>() + + async testDns() { + const loader = this.loader.open().subscribe() + + try { + await this.api.testDomain({ + fqdn: this.context.data.fqdn, + gateway: this.context.data.gateway.id, + }) + return true + } catch (e: any) { + this.errorService.handleError(e) + return false + } finally { + loader.unsubscribe() + } + } +} + +export const DNS = new PolymorpheusComponent(DnsComponent) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domain.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domain.service.ts index 59f6a3ec1..649b3b3fa 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domain.service.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/domain.service.ts @@ -6,7 +6,7 @@ import { LoadingService, } from '@start9labs/shared' import { toSignal } from '@angular/core/rxjs-interop' -import { ISB, utils } from '@start9labs/start-sdk' +import { ISB, T, utils } from '@start9labs/start-sdk' import { filter, map } from 'rxjs' import { FormComponent } from 'src/app/routes/portal/components/form.component' import { ApiService } from 'src/app/services/api/embassy-api.service' @@ -15,9 +15,26 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { PatchDB } from 'patch-db-client' import { DataModel } from 'src/app/services/patch-db/data-model' import { toAuthorityName } from 'src/app/utils/acme' +import { parse } from 'tldts' +import { RR } from 'src/app/services/api/api.types' +import { DNS } from './dns.component' // @TODO translations +export type MappedDomain = { + fqdn: string + subdomain: string | null + gateway: { + id: string + name: string | null + ipInfo: T.IpInfo | null + } + authority: { + url: string | null + name: string | null + } +} + @Injectable() export class DomainService { private readonly patch = inject>(PatchDB) @@ -30,57 +47,45 @@ export class DomainService { readonly data = toSignal( this.patch.watch$('serverInfo', 'network').pipe( - map(network => { - return { - gateways: Object.entries(network.networkInterfaces).reduce< - Record - >( - (obj, [id, n]) => ({ - ...obj, - [id]: n.ipInfo?.name || '', - }), - {}, - ), - // @TODO use real data - domains: [ - { - domain: 'blog.mydomain.com', + map(({ networkInterfaces, domains, acme }) => ({ + gateways: Object.entries(networkInterfaces).reduce< + Record + >( + (obj, [id, n]) => ({ + ...obj, + [id]: n.ipInfo?.name || '', + }), + {}, + ), + domains: Object.entries(domains).map( + ([fqdn, { gateway, acme }]) => + ({ + fqdn, + subdomain: parse(fqdn).subdomain, gateway: { - id: 'wireguard1', - name: 'StartTunnel', + id: gateway, + ipInfo: networkInterfaces[gateway]?.ipInfo || null, }, authority: { - url: 'https://acme-v02.api.letsencrypt.org/directory', - name: `Let's Encrypt`, + url: acme, + name: toAuthorityName(acme), }, - }, - { - domain: 'store.mydomain.com', - gateway: { - id: 'eth0', - name: 'Ethernet', - }, - authority: { - url: 'local', - name: toAuthorityName(null), - }, - }, - ], - authorities: Object.keys(network.acme).reduce>( - (obj, url) => ({ - ...obj, - [url]: toAuthorityName(url), - }), - { local: toAuthorityName(null) }, - ), - } - }), + }) as MappedDomain, + ), + authorities: Object.keys(acme).reduce>( + (obj, url) => ({ + ...obj, + [url]: toAuthorityName(url), + }), + { local: toAuthorityName(null) }, + ), + })), ), ) async add() { const addSpec = ISB.InputSpec.of({ - domain: ISB.Value.text({ + fqdn: ISB.Value.text({ name: 'Domain', description: 'Enter a domain/subdomain. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com. In any case, the domain you enter and all possible subdomains of the domain will be available for assignment in StartOS', @@ -92,26 +97,31 @@ export class DomainService { }) this.formDialog.open(FormComponent, { - label: 'Add Domain' as any, + label: 'Add domain', data: { spec: await configBuilderToSpec(addSpec), buttons: [ { text: 'Save', - handler: (input: typeof addSpec._TYPE) => this.save(input), + handler: (input: typeof addSpec._TYPE) => + this.save({ + fqdn: input.fqdn, + gateway: input.gateway, + acme: input.authority === 'local' ? null : input.authority, + }), }, ], }, }) } - async edit(domain: any) { + async edit(domain: MappedDomain) { const editSpec = ISB.InputSpec.of({ ...this.gatewaysAndAuthorities(), }) this.formDialog.open(FormComponent, { - label: 'Edit Domain', + label: 'Edit domain', data: { spec: await configBuilderToSpec(editSpec), buttons: [ @@ -119,20 +129,21 @@ export class DomainService { text: 'Save', handler: (input: typeof editSpec._TYPE) => this.save({ - domain: domain.domain, - ...input, + fqdn: domain.fqdn, + gateway: input.gateway, + acme: input.authority === 'local' ? null : input.authority, }), }, ], value: { gateway: domain.gateway.id, - authority: domain.authority.url, + authority: domain.authority.url || 'local', }, }, }) } - remove(domain: any) { + remove(fqdn: string) { this.dialog .openConfirm({ label: 'Are you sure?', size: 's' }) .pipe(filter(Boolean)) @@ -140,7 +151,7 @@ export class DomainService { const loader = this.loader.open('Deleting').subscribe() try { - // @TODO API + await this.api.removeDomain({ fqdn }) } catch (e: any) { this.errorService.handleError(e) } finally { @@ -149,20 +160,17 @@ export class DomainService { }) } - showDns(domain: any) { - // @TODO + showDns(domain: MappedDomain) { + this.dialog + .openComponent(DNS, { label: 'Manage DNS', data: domain }) + .subscribe() } - testDns(domain: any) { - // @TODO - } - - // @TODO different endpoints for create and edit? - private async save(params: any) { + private async save(params: RR.AddDomainReq) { const loader = this.loader.open('Saving').subscribe() try { - // @TODO API + await this.api.addDomain(params) return true } catch (e: any) { this.errorService.handleError(e) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts index 8694a0cf5..212d4f4bb 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/domains/domains/item.component.ts @@ -11,14 +11,14 @@ import { TuiDropdown, TuiTextfield, } from '@taiga-ui/core' -import { DomainService } from './domain.service' +import { DomainService, MappedDomain } from './domain.service' @Component({ selector: 'tr[domain]', template: ` @if (domain(); as domain) { - {{ domain.domain }} - {{ domain.gateway.name }} + {{ domain.fqdn }} + {{ domain.gateway.ipInfo?.name || '-' }} {{ domain.authority.name }} - @@ -64,7 +56,7 @@ import { DomainService } from './domain.service' new iconStart="@tui.trash" class="g-negative" - (click)="domainService.remove(domain)" + (click)="domainService.remove(domain.fqdn)" > {{ 'Delete' | i18n }} @@ -96,7 +88,7 @@ import { DomainService } from './domain.service' export class DomainItemComponent { protected readonly domainService = inject(DomainService) - readonly domain = input.required() + readonly domain = input.required() open = false } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts index fd0824214..7be07ade3 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts @@ -18,7 +18,7 @@ import { TitleDirective } from 'src/app/services/title.service' import { TuiHeader } from '@taiga-ui/layout' import { map } from 'rxjs' import { ISB } from '@start9labs/start-sdk' -import { GatewayWithID } from './item.component' +import { GatewayPlus } from './item.component' @Component({ template: ` @@ -87,19 +87,24 @@ export default class GatewaysComponent { .watch$('serverInfo', 'network', 'networkInterfaces') .pipe( map(gateways => - Object.entries(gateways).map( - ([id, val]) => - ({ - ...val, - id, - }) as GatewayWithID, - ), + Object.entries(gateways) + .filter(([_, val]) => !!val.ipInfo) + .map( + ([id, val]) => + ({ + ...val, + id, + ipv4: val.ipInfo?.subnets + .filter(s => !s.includes('::')) + .map(s => s.split('/')[0]), + }) as GatewayPlus, + ), ), ) async add() { this.formDialog.open(FormComponent, { - label: 'Add Gateway', + label: 'Add gateway', data: { spec: await configBuilderToSpec(gatewaySpec), buttons: [ diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts index ab07129d3..e5ce25cf9 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts @@ -24,55 +24,64 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' -export type GatewayWithID = T.NetworkInterfaceInfo & { +export type GatewayPlus = T.NetworkInterfaceInfo & { id: string ipInfo: T.IpInfo + ipv4: string[] } @Component({ selector: 'tr[proxy]', template: ` - {{ proxy().ipInfo.name }} - {{ proxy().ipInfo.deviceType || '-' }} - - {{ proxy().public ? ('Public' | i18n) : ('Private' | i18n) }} - - - {{ proxy().ipInfo.subnets[0] }} - {{ proxy().ipInfo.wanIp }} - - - - @if (proxy().ipInfo.deviceType === 'wireguard') { + {{ proxy.ipInfo.wanIp || ('Error' | i18n) }} + + + - } - - - + @if (proxy.ipInfo.deviceType === 'wireguard') { + + + + } + + + + } `, styles: ` td:last-child { @@ -106,7 +115,7 @@ export type GatewayWithID = T.NetworkInterfaceInfo & { grid-column: span 2; &::before { - content: 'LAN IPs: '; + content: 'LAN IP: '; color: var(--tui-text-primary); } } @@ -133,7 +142,7 @@ export class GatewaysItemComponent { private readonly api = inject(ApiService) private readonly formDialog = inject(FormDialogService) - readonly proxy = input.required() + readonly proxy = input.required() open = false diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts index 87a0c19ec..f5d187d57 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts @@ -3,7 +3,7 @@ import { i18nPipe } from '@start9labs/shared' import { TuiSkeleton } from '@taiga-ui/kit' import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' import { TableComponent } from 'src/app/routes/portal/components/table.component' -import { GatewaysItemComponent, GatewayWithID } from './item.component' +import { GatewaysItemComponent, GatewayPlus } from './item.component' @Component({ selector: '[gateways]', @@ -13,7 +13,7 @@ import { GatewaysItemComponent, GatewayWithID } from './item.component' 'Name', 'Type', 'Access', - $any('LAN IPs'), + $any('LAN IP'), $any('WAN IP'), null, ]" @@ -25,7 +25,7 @@ import { GatewaysItemComponent, GatewayWithID } from './item.component' @if (gateways()) { - + No gateways } @else { @@ -45,6 +45,6 @@ import { GatewaysItemComponent, GatewayWithID } from './item.component' PlaceholderComponent, ], }) -export class GatewaysTableComponent { +export class GatewaysTableComponent { readonly gateways = input(null) } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts index d64e22c35..2a169e8aa 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts @@ -102,7 +102,7 @@ import { SystemWipeComponent } from './wipe.component'
- {{ 'Browser Tab Title' | i18n }} + {{ 'Browser tab title' | i18n }} {{ 'Customize the name appearing in your browser tab' | i18n }} @@ -302,7 +302,7 @@ export default class SystemGeneralComponent { onTitle() { const sub = this.dialog .openPrompt({ - label: 'Browser Tab Title', + label: 'Browser tab title', data: { label: 'Device Name', message: diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/table.component.ts index 71e558db6..3a4803998 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/table.component.ts @@ -144,7 +144,7 @@ export class WifiTableComponent { await this.component.saveAndConnect(network.ssid) } else { this.formDialog.open>(FormComponent, { - label: 'Password Needed', + label: 'Password needed', data: { spec: wifiSpec.spec, buttons: [ diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index f2647d6a9..553b2f9f1 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -234,7 +234,28 @@ export namespace RR { } export type CreateBackupRes = null - // tunnel + // network + + export type AddDomainReq = { + fqdn: string + gateway: string + acme: string | null + } // net.domain.add + export type AddDomainRes = null + + export type RemoveDomainReq = { + fqdn: string + } // net.domain.remove + export type RemoveDomainRes = null + + export type TestDomainReq = { + fqdn: string + gateway: string + } // net.domain.test + export type TestDomainRes = { + root: boolean + wildcard: boolean + } export type AddTunnelReq = { name: string @@ -255,7 +276,7 @@ export namespace RR { export type RemoveTunnelRes = null export type InitAcmeReq = { - provider: 'letsencrypt' | 'letsencrypt-staging' | string + provider: string contact: string[] } export type InitAcmeRes = null @@ -288,19 +309,19 @@ export namespace RR { export type ServerRemoveOnionReq = ServerAddOnionReq // server.host.address.onion.remove export type RemoveOnionRes = null - export type ServerAddDomainReq = { + export type OsUiAddDomainReq = { // server.host.address.domain.add domain: string // FQDN private: boolean - acme: string | null // "letsencrypt" | "letsencrypt-staging" | Url | null + acme: string | null // Url | null } - export type AddDomainRes = null + export type OsUiAddDomainRes = null - export type ServerRemoveDomainReq = { + export type OsUiRemoveDomainReq = { // server.host.address.domain.remove domain: string // FQDN } - export type RemoveDomainRes = null + export type OsUiRemoveDomainRes = null export type PkgBindingSetPublicReq = ServerBindingSetPublicReq & { // package.host.binding.set-public @@ -316,17 +337,19 @@ export namespace RR { export type PkgRemoveOnionReq = PkgAddOnionReq // package.host.address.onion.remove - export type PkgAddDomainReq = ServerAddDomainReq & { + export type PkgAddDomainReq = OsUiAddDomainReq & { // package.host.address.domain.add package: T.PackageId // string host: T.HostId // string } + export type PkgAddDomainRes = null - export type PkgRemoveDomainReq = ServerRemoveDomainReq & { + export type PkgRemoveDomainReq = OsUiRemoveDomainReq & { // package.host.address.domain.remove package: T.PackageId // string host: T.HostId // string } + export type PkgRemoveDomainRes = null export type GetPackageLogsReq = FetchLogsReq & { id: string } // package.logs export type GetPackageLogsRes = FetchLogsRes @@ -624,32 +647,6 @@ export type DependencyErrorTransitive = { // @TODO 041 // export namespace RR041 { -// // ** domains ** - -// export type ClaimStart9ToReq = { gatewayId: string } // net.domain.me.claim -// export type ClaimStart9ToRes = null - -// export type DeleteStart9ToReq = {} // net.domain.me.delete -// export type DeleteStart9ToRes = null - -// export type AddDomainReq = { -// hostname: string -// provider: { -// name: string -// username: string | null -// password: string | null -// } -// gatewayId: string -// } // net.domain.add -// export type AddDomainRes = null - -// export type DeleteDomainReq = { hostname: string } // net.domain.delete -// export type DeleteDomainRes = null - -// // port forwards - -// export type OverridePortReq = { target: number; port: number } // net.port-forwards.override -// export type OverridePortRes = null // // ** automated backups ** @@ -731,20 +728,6 @@ export type DependencyErrorTransitive = { // @TODO 041 types -// export type AppMetrics = { -// memory: { -// percentageUsed: MetricData -// used: MetricData -// } -// cpu: { -// percentageUsed: MetricData -// } -// disk: { -// percentageUsed: MetricData -// used: MetricData -// } -// } - // export type RemoteBackupTarget = CifsBackupTarget | CloudBackupTarget // export type BackupTarget = RemoteBackupTarget | DiskBackupTarget diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index d0f69a3e7..7683b9090 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -184,33 +184,15 @@ export abstract class ApiService { // @TODO 041 - // abstract setOutboundProxy( - // params: RR.SetOutboundTunnelReq, - // ): Promise - // ** domains ** // @TODO 041 - // abstract claimStart9ToDomain( - // params: RR.ClaimStart9ToReq, - // ): Promise + abstract addDomain(params: RR.AddDomainReq): Promise - // abstract deleteStart9ToDomain( - // params: RR.DeleteStart9ToReq, - // ): Promise + abstract removeDomain(params: RR.RemoveDomainReq): Promise - // abstract addDomain(params: RR.AddDomainReq): Promise - - // abstract deleteDomain(params: RR.DeleteDomainReq): Promise - - // ** port forwards ** - - // @TODO 041 - - // abstract overridePortForward( - // params: RR.OverridePortReq, - // ): Promise + abstract testDomain(params: RR.TestDomainReq): Promise // wifi @@ -387,13 +369,13 @@ export abstract class ApiService { params: RR.ServerRemoveOnionReq, ): Promise - abstract serverAddDomain( - params: RR.ServerAddDomainReq, - ): Promise + abstract osUiAddDomain( + params: RR.OsUiAddDomainReq, + ): Promise - abstract serverRemoveDomain( - params: RR.ServerRemoveDomainReq, - ): Promise + abstract osUiRemoveDomain( + params: RR.OsUiRemoveDomainReq, + ): Promise abstract pkgBindingSetPubic( params: RR.PkgBindingSetPublicReq, @@ -405,9 +387,9 @@ export abstract class ApiService { params: RR.PkgRemoveOnionReq, ): Promise - abstract pkgAddDomain(params: RR.PkgAddDomainReq): Promise + abstract pkgAddDomain(params: RR.PkgAddDomainReq): Promise abstract pkgRemoveDomain( params: RR.PkgRemoveDomainReq, - ): Promise + ): Promise } diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index 73cbd50e5..3b4b270d9 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -358,41 +358,19 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'net.tunnel.remove', params }) } - // async setOutboundProxy( - // params: RR.SetOutboundTunnelReq, - // ): Promise { - // return this.rpcRequest({ method: 'server.proxy.set-outbound', params }) - // } - // domains - // async claimStart9ToDomain( - // params: RR.ClaimStart9ToReq, - // ): Promise { - // return this.rpcRequest({ method: 'net.domain.me.claim', params }) - // } + async addDomain(params: RR.AddDomainReq): Promise { + return this.rpcRequest({ method: 'net.domain.add', params }) + } - // async deleteStart9ToDomain( - // params: RR.DeleteStart9ToReq, - // ): Promise { - // return this.rpcRequest({ method: 'net.domain.me.delete', params }) - // } + async removeDomain(params: RR.RemoveDomainReq): Promise { + return this.rpcRequest({ method: 'net.domain.remove', params }) + } - // async addDomain(params: RR.AddDomainReq): Promise { - // return this.rpcRequest({ method: 'net.domain.add', params }) - // } - - // async deleteDomain(params: RR.DeleteDomainReq): Promise { - // return this.rpcRequest({ method: 'net.domain.delete', params }) - // } - - // port forwards - - // async overridePortForward( - // params: RR.OverridePortReq, - // ): Promise { - // return this.rpcRequest({ method: 'net.port-forwards.override', params }) - // } + async testDomain(params: RR.TestDomainReq): Promise { + return this.rpcRequest({ method: 'net.domain.test', params }) + } // wifi @@ -685,18 +663,18 @@ export class LiveApiService extends ApiService { }) } - async serverAddDomain( - params: RR.ServerAddDomainReq, - ): Promise { + async osUiAddDomain( + params: RR.OsUiAddDomainReq, + ): Promise { return this.rpcRequest({ method: 'server.host.address.domain.add', params, }) } - async serverRemoveDomain( - params: RR.ServerRemoveDomainReq, - ): Promise { + async osUiRemoveDomain( + params: RR.OsUiRemoveDomainReq, + ): Promise { return this.rpcRequest({ method: 'server.host.address.domain.remove', params, @@ -728,7 +706,7 @@ export class LiveApiService extends ApiService { }) } - async pkgAddDomain(params: RR.PkgAddDomainReq): Promise { + async pkgAddDomain(params: RR.PkgAddDomainReq): Promise { return this.rpcRequest({ method: 'package.host.address.domain.add', params, @@ -737,7 +715,7 @@ export class LiveApiService extends ApiService { async pkgRemoveDomain( params: RR.PkgRemoveDomainReq, - ): Promise { + ): Promise { return this.rpcRequest({ method: 'package.host.address.domain.remove', params, diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 2a77c59ea..01c995dbb 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -601,113 +601,50 @@ export class MockApiService extends ApiService { return null } - // async setOutboundProxy( - // params: RR.SetOutboundTunnelReq, - // ): Promise { - // await pauseFor(2000) - - // const patch: ReplaceOperation[] = [ - // { - // op: PatchOp.REPLACE, - // path: '/serverInfo/network/outboundInterface', - // value: params.id, - // }, - // ] - // this.mockRevision(patch) - - // return null - // } - // domains - // async claimStart9ToDomain( - // params: RR.ClaimStart9ToReq, - // ): Promise { - // await pauseFor(2000) + async addDomain(params: RR.AddDomainReq): Promise { + await pauseFor(2000) - // const patch = [ - // { - // op: PatchOp.REPLACE, - // path: '/serverInfo/network/start9To', - // value: { - // subdomain: 'xyz', - // gatewayId: params.gatewayId, - // }, - // }, - // ] - // this.mockRevision(patch) + const patch = [ + { + op: PatchOp.REPLACE, + path: `/serverInfo/network/domains`, + value: { + [params.fqdn]: { + gateway: params.gateway, + acme: params.acme, + }, + }, + }, + ] + this.mockRevision(patch) - // return null - // } + return null + } - // async deleteStart9ToDomain( - // params: RR.DeleteStart9ToReq, - // ): Promise { - // await pauseFor(2000) - // const patch = [ - // { - // op: PatchOp.REPLACE, - // path: '/serverInfo/network/start9To', - // value: null, - // }, - // ] - // this.mockRevision(patch) + async removeDomain(params: RR.RemoveDomainReq): Promise { + await pauseFor(2000) + const patch = [ + { + op: PatchOp.REPLACE, + path: '/serverInfo/network/domains', + value: {}, + }, + ] + this.mockRevision(patch) - // return null - // } + return null + } - // async addDomain(params: RR.AddDomainReq): Promise { - // await pauseFor(2000) + async testDomain(params: RR.TestDomainReq): Promise { + await pauseFor(2000) - // const patch = [ - // { - // op: PatchOp.REPLACE, - // path: `/serverInfo/network/domains`, - // value: { - // [params.hostname]: { - // gatewayId: params.gatewayId, - // provider: params.provider.name, - // }, - // }, - // }, - // ] - // this.mockRevision(patch) - - // return null - // } - - // async deleteDomain(params: RR.DeleteDomainReq): Promise { - // await pauseFor(2000) - // const patch = [ - // { - // op: PatchOp.REPLACE, - // path: '/serverInfo/network/domains', - // value: {}, - // }, - // ] - // this.mockRevision(patch) - - // return null - // } - - // port forwards - - // async overridePortForward( - // params: RR.OverridePortReq, - // ): Promise { - // await pauseFor(2000) - - // const patch = [ - // { - // op: PatchOp.REPLACE, - // path: '/serverInfo/network/wanConfig/forwards/0/override', - // value: params.port, - // }, - // ] - // this.mockRevision(patch) - - // return null - // } + return { + root: true, + wildcard: true, + } + } // wifi @@ -1496,7 +1433,9 @@ export class MockApiService extends ApiService { return null } - async serverAddDomain(params: RR.PkgAddDomainReq): Promise { + async osUiAddDomain( + params: RR.OsUiAddDomainReq, + ): Promise { await pauseFor(2000) const patch: Operation[] = [ @@ -1529,9 +1468,9 @@ export class MockApiService extends ApiService { return null } - async serverRemoveDomain( - params: RR.PkgRemoveDomainReq, - ): Promise { + async osUiRemoveDomain( + params: RR.OsUiRemoveDomainReq, + ): Promise { await pauseFor(2000) const patch: RemoveOperation[] = [ @@ -1613,7 +1552,7 @@ export class MockApiService extends ApiService { return null } - async pkgAddDomain(params: RR.PkgAddDomainReq): Promise { + async pkgAddDomain(params: RR.PkgAddDomainReq): Promise { await pauseFor(2000) const patch: Operation[] = [ @@ -1648,7 +1587,7 @@ export class MockApiService extends ApiService { async pkgRemoveDomain( params: RR.PkgRemoveDomainReq, - ): Promise { + ): Promise { await pauseFor(2000) const patch: RemoveOperation[] = [ diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 9774ab4cc..29c42ed91 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -32,6 +32,16 @@ export const mockPatchData: DataModel = { contact: ['mailto:support@start9.com'], }, }, + domains: { + 'cloud.private.com': { + gateway: 'eth0', + acme: null, + }, + 'public.com': { + gateway: 'wireguard1', + acme: 'https://acme-v02.api.letsencrypt.org/directory', + }, + }, host: { bindings: { 80: { diff --git a/web/projects/ui/src/app/services/patch-db/data-model.ts b/web/projects/ui/src/app/services/patch-db/data-model.ts index 9efcbfeac..bd7a1ffb4 100644 --- a/web/projects/ui/src/app/services/patch-db/data-model.ts +++ b/web/projects/ui/src/app/services/patch-db/data-model.ts @@ -1,7 +1,20 @@ import { Languages } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' -export type DataModel = T.Public & { ui: UIData; packageData: AllPackageData } +export type DataModel = T.Public & { + ui: UIData + packageData: AllPackageData + serverInfo: T.ServerInfo & { + network: T.NetworkInfo & { + domains: { + [fqdn: string]: { + gateway: string + acme: string | null + } + } + } + } +} export type UIData = { name: string | null @@ -11,22 +24,6 @@ export type UIData = { language: Languages } -export type NetworkInfo = T.NetworkInfo & { - // @TODO 041 - // start9To: { - // subdomain: string - // gatewayId: string - // } | null - // domains: { - // [key: string]: Domain - // } - // wanConfig: { - // upnp: boolean - // forwards: PortForward[] - // } - // outboundProxy: string | null -} - export type PackageDataEntry = T.PackageDataEntry & { stateInfo: T diff --git a/web/projects/ui/src/app/services/proxy.service.ts b/web/projects/ui/src/app/services/proxy.service.ts index 3d2ec71a6..c0185f17d 100644 --- a/web/projects/ui/src/app/services/proxy.service.ts +++ b/web/projects/ui/src/app/services/proxy.service.ts @@ -50,7 +50,7 @@ // const options: Partial< // TuiDialogOptions> // > = { -// label: 'Outbound Proxy', +// label: 'Outbound proxy', // data: { // spec: await configBuilderToSpec(config), // buttons: [