mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
better shared hostname approach, and improve look-feel of addresses tables
This commit is contained in:
1
web/projects/shared/assets/icons/letsencrypt.svg
Normal file
1
web/projects/shared/assets/icons/letsencrypt.svg
Normal 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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user