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

This commit is contained in:
Matt Hill
2026-03-04 23:24:08 -07:00
parent e077b5425b
commit e71023a3a7
14 changed files with 167 additions and 57 deletions

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

@@ -705,4 +705,8 @@ export default {
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',
} satisfies i18n

View File

@@ -705,4 +705,8 @@ export const ENGLISH: Record<string, number> = {
'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
}

View File

@@ -705,4 +705,8 @@ export default {
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',
} satisfies i18n

View File

@@ -705,4 +705,8 @@ export default {
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',
} satisfies i18n

View File

@@ -705,4 +705,8 @@ export default {
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',
} satisfies i18n

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,7 +139,7 @@ export class InterfaceAddressesComponent {
}),
}),
),
note: await this.getSharedHostNote(),
note: this.getSharedHostNote(),
buttons: [
{
text: this.i18n.transform('Save')!,
@@ -191,7 +198,7 @@ export class InterfaceAddressesComponent {
size: 's',
data: {
spec: await configBuilderToSpec(addSpec),
note: await this.getSharedHostNote(),
note: this.getSharedHostNote(),
buttons: [
{
text: this.i18n.transform('Save')!,
@@ -235,26 +242,11 @@ export class InterfaceAddressesComponent {
}
}
private async getSharedHostNote(): Promise<string> {
const iface = this.value()
const pkgId = this.packageId()
if (!iface || !pkgId) return ''
private getSharedHostNote(): string {
const names = this.value()?.sharedHostNames
if (!names?.length) return ''
const pkg = await firstValueFrom(
this.patch.watch$('packageData', pkgId),
)
if (!pkg) return ''
const hostId = iface.addressInfo.hostId
const otherNames = Object.values(pkg.serviceInterfaces)
.filter(
si => si.addressInfo.hostId === hostId && si.id !== iface.id,
)
.map(si => si.name)
if (!otherNames.length) return ''
return `${this.i18n.transform('This domain will also apply to')} ${otherNames.join(', ')}`
return `${this.i18n.transform('This domain will also apply to')} ${names.join(', ')}`
}
private async savePublicDomain(

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':
@@ -337,4 +337,5 @@ export type MappedServiceInterface = T.ServiceInterface & {
gatewayGroups: GatewayAddressGroup[]
pluginGroups: PluginAddressGroup[]
addSsl: boolean
sharedHostNames: 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

@@ -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

@@ -652,7 +652,7 @@ export const mockPatchData: DataModel = {
publicDomains: {
'bitcoin.example.com': {
gateway: 'eth0',
acme: null,
acme: 'https://acme-v02.api.letsencrypt.org/directory',
},
},
privateDomains: {