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

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