* help ios downlaod .crt and add begin add masked for addresses

* only require and show CA for public domain if addSsl

* fix type and revert i18n const

* feat: add address masking and adjust design (#3088)

* feat: add address masking and adjust design

* update lockfile

* chore: move eye button to actions

* chore: refresh notifications and handle action error

* static width for health check name

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>

* hide certificate authorities tab

* alpha.17

* add waiting health check status

* remove "on" from waiting message

* reject on abort in `.watch`

* id migration: nostr -> nostr-rs-relay

* health check waiting state

* use interface type for launch button

* better wording for masked

* cleaner

* sdk improvements

* fix type error

* fix notification badge issue

---------

Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Matt Hill
2025-12-31 11:30:57 -07:00
committed by GitHub
parent 96ae532879
commit c9a7f519b9
99 changed files with 1535 additions and 1120 deletions

1169
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "startos-ui",
"version": "0.4.0-alpha.16",
"version": "0.4.0-alpha.17",
"author": "Start9 Labs, Inc",
"homepage": "https://start9.com/",
"license": "MIT",

View File

@@ -4,7 +4,8 @@
"https://registry.start9.com/": "Start9 Registry",
"https://community-registry.start9.com/": "Community Registry",
"https://beta-registry.start9.com/": "Start9 Beta Registry",
"https://community-beta-registry.start9.com/": "Community Beta Registry"
"https://community-beta-registry.start9.com/": "Community Beta Registry",
"https://alpha-registry-x.start9.com/": "Start9 Alpha Registry"
},
"startosRegistry": "https://beta-registry.start9.com/",
"snakeHighScore": 0

View File

@@ -5,7 +5,6 @@ import {
i18nPipe,
SharedPipesModule,
} from '@start9labs/shared'
import { TuiLet } from '@taiga-ui/cdk'
import {
TuiAppearance,
TuiButton,
@@ -29,7 +28,6 @@ import { MenuComponent } from './menu.component'
TuiButton,
CategoriesModule,
StoreIconComponentModule,
TuiLet,
TuiAppearance,
TuiIcon,
TuiSkeleton,

View File

@@ -2,18 +2,11 @@ import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
import { TuiLet } from '@taiga-ui/cdk'
import { ItemComponent } from './item.component'
@NgModule({
declarations: [ItemComponent],
exports: [ItemComponent],
imports: [
CommonModule,
RouterModule,
SharedPipesModule,
TickerComponent,
TuiLet,
],
imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent],
})
export class ItemModule {}

View File

@@ -146,7 +146,9 @@ export default class SuccessPage implements AfterViewInit {
.getElementById('cert')
?.setAttribute(
'href',
`data:application/x-x509-ca-cert;base64,${encodeURIComponent(this.cert!)}`,
URL.createObjectURL(
new Blob([this.cert!], { type: 'application/octet-stream' }),
),
)
const html = this.documentation?.nativeElement.innerHTML || ''

View File

@@ -1,7 +1,7 @@
import { AsyncPipe } from '@angular/common'
import { Component, ElementRef, inject, input } from '@angular/core'
import { Component, ElementRef, inject } from '@angular/core'
import {
INTERSECTION_ROOT,
WA_INTERSECTION_ROOT,
WaIntersectionObserver,
} from '@ng-web-apis/intersection-observer'
import { WaMutationObserver } from '@ng-web-apis/mutation-observer'
@@ -36,12 +36,7 @@ import { SetupLogsService } from '../../services/setup-logs.service'
NgDompurifyPipe,
TuiScrollbar,
],
providers: [
{
provide: INTERSECTION_ROOT,
useExisting: ElementRef,
},
],
providers: [{ provide: WA_INTERSECTION_ROOT, useExisting: ElementRef }],
})
export class LogsWindowComponent {
readonly logs$ = inject(SetupLogsService)

View File

@@ -40,7 +40,9 @@ import { i18nKey } from '../i18n/i18n.providers'
class="button"
[iconStart]="masked ? '@tui.eye' : '@tui.eye-off'"
(click)="masked = !masked"
></button>
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
</tui-textfield>
<footer class="g-buttons">

View File

@@ -1,25 +1,22 @@
import { Directive, inject, DOCUMENT } from '@angular/core'
import { Directive, DOCUMENT, inject } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import {
MutationObserverService,
provideMutationObserverInit,
WaMutationObserverService,
} from '@ng-web-apis/mutation-observer'
import { tuiInjectElement } from '@taiga-ui/cdk'
@Directive({
selector: '[safeLinks]',
providers: [
MutationObserverService,
provideMutationObserverInit({
childList: true,
subtree: true,
}),
WaMutationObserverService,
provideMutationObserverInit({ childList: true, subtree: true }),
],
})
export class SafeLinksDirective {
private readonly doc = inject(DOCUMENT)
private readonly el = tuiInjectElement()
private readonly sub = inject(MutationObserverService)
private readonly sub = inject(WaMutationObserverService)
.pipe(takeUntilDestroyed())
.subscribe(() => {
Array.from(this.doc.links)

View File

@@ -90,6 +90,9 @@ export default {
90: 'Root-CA ist vertrauenswürdig!',
91: 'Installierte Dienste',
92: 'Diagnosen für den Tor-Daemon auf diesem Server',
93: 'Fingerabdruck kopieren',
94: 'Warten',
95: 'Warten auf',
96: 'Öffentliche Domain hinzufügen',
97: 'Wird entfernt',
100: 'Nicht gespeicherte Änderungen',
@@ -578,7 +581,7 @@ export default {
611: 'Keine Service-Schnittstellen',
612: 'Grund',
613: 'Private Gateways für die StartOS-Benutzeroberfläche können nicht deaktiviert werden',
614: 'CA-Fingerabdruck',
614: 'Root-CA',
615: 'DHCP-Server',
616: 'DHCP-Server können nicht bearbeitet werden',
617: 'Statisch',
@@ -592,4 +595,5 @@ export default {
625: 'Eine andere Version auswählen',
626: 'Hochladen',
627: 'UI öffnen',
628: 'In Zwischenablage kopiert',
} satisfies i18n

View File

@@ -89,6 +89,9 @@ export const ENGLISH = {
'Root CA Trusted!': 90,
'Installed services': 91, // as in, software services installed on this computer
'Diagnostics for the Tor daemon on this server': 92,
'Copy fingerprint': 93, // as in the fingerprint of a root certificate authority
'Waiting': 94,
'Waiting on': 95, // as in "awaiting"
'Add public domain': 96,
'Removing': 97,
'Unsaved changes': 100,
@@ -577,7 +580,7 @@ export const ENGLISH = {
'No service interfaces': 611, // as in, there are no available interfaces (API, UI, etc) for this software application
'Reason': 612, // as in, an explanation for something
'Cannot disable private gateways for StartOS UI': 613,
'CA fingerprint': 614, // as in, the unique, fixed-length digital identifier generated from a certificate's data using a cryptographic hash function
'Root CA': 614, // as in, the unique, fixed-length digital identifier generated from a certificate's data using a cryptographic hash function
'DHCP Servers': 615,
'Cannot edit DHCP servers': 616,
'Static': 617, // as in, unchanging
@@ -591,4 +594,5 @@ export const ENGLISH = {
'Select another version': 625,
'Upload': 626, // as in, upload a file
'Open UI': 627, // as in, upload a file
'Copied to clipboard': 628,
} as const

View File

@@ -90,6 +90,9 @@ export default {
90: '¡CA raíz confiable!',
91: 'Servicios instalados',
92: 'Diagnósticos para el demonio Tor en este servidor',
93: 'Copiar huella digital',
94: 'Esperando',
95: 'En espera de',
96: 'Agregar dominio público',
97: 'Eliminando',
100: 'Cambios no guardados',
@@ -578,7 +581,7 @@ export default {
611: 'Sin interfaces de servicio',
612: 'Razón',
613: 'No se pueden deshabilitar las puertas de enlace privadas para la interfaz de usuario de StartOS',
614: 'Huella digital de la CA',
614: 'CA raíz',
615: 'Servidores DHCP',
616: 'No se pueden editar los servidores DHCP',
617: 'Estático',
@@ -592,4 +595,5 @@ export default {
625: 'Seleccionar otra versión',
626: 'Subir',
627: 'Abrir UI',
628: 'Copiado al portapapeles',
} satisfies i18n

View File

@@ -90,6 +90,9 @@ export default {
90: 'Certificat racine approuvé !',
91: 'Services installés',
92: 'Diagnostics pour le service Tor sur ce serveur',
93: 'Copier lempreinte',
94: 'En attente',
95: 'En attente de',
96: 'Ajouter un domaine public',
97: 'Suppression',
100: 'Modifications non enregistrées',
@@ -578,7 +581,7 @@ export default {
611: 'Aucune interface de service',
612: 'Raison',
613: "Impossible de désactiver les passerelles privées pour l'interface utilisateur StartOS",
614: 'Empreinte de lAC',
614: 'CA racine',
615: 'Serveurs DHCP',
616: 'Impossible de modifier les serveurs DHCP',
617: 'Statique',
@@ -592,4 +595,5 @@ export default {
625: 'Sélectionner une autre version',
626: 'Téléverser',
627: 'Ouvrir UI',
628: 'Copié dans le presse-papiers',
} satisfies i18n

View File

@@ -90,6 +90,9 @@ export default {
90: 'Główny certyfikat CA zaufany!',
91: 'Zainstalowane usługi',
92: 'Diagnostyka demona Tor na tym serwerze',
93: 'Kopiuj odcisk palca',
94: 'Oczekiwanie',
95: 'Oczekiwanie na',
96: 'Dodaj domenę publiczną',
97: 'Usuwanie',
100: 'Niezapisane zmiany',
@@ -578,7 +581,7 @@ export default {
611: 'Brak interfejsów usług',
612: 'Powód',
613: 'Nie można wyłączyć prywatnych bram dla interfejsu użytkownika StartOS',
614: 'Odcisk palca CA',
614: 'głównego CA',
615: 'Serwery DHCP',
616: 'Nie można edytować serwerów DHCP',
617: 'Statyczny',
@@ -592,4 +595,5 @@ export default {
625: 'Wybierz inną wersję',
626: 'Prześlij',
627: 'Otwórz UI',
628: 'Skopiowano do schowka',
} satisfies i18n

View File

@@ -1,5 +1,5 @@
import { forwardRef, signal } from '@angular/core'
import { tuiCreateToken, tuiProvide } from '@taiga-ui/cdk'
import { forwardRef, InjectionToken, signal } from '@angular/core'
import { tuiProvide } from '@taiga-ui/cdk'
import {
TuiLanguageName,
tuiLanguageSwitcher,
@@ -11,12 +11,19 @@ import { i18nService } from './i18n.service'
export type i18nKey = keyof typeof ENGLISH
export type i18n = Record<(typeof ENGLISH)[i18nKey], string>
export const I18N = tuiCreateToken(signal<i18n | null>(null))
export const I18N_LOADER =
tuiCreateToken<(lang: TuiLanguageName) => Promise<i18n>>()
export const I18N_STORAGE = tuiCreateToken<
export const I18N = new InjectionToken('', {
factory: () => signal<i18n | null>(null),
})
export const I18N_LOADER = new InjectionToken<
(lang: TuiLanguageName) => Promise<i18n>
>('')
export const I18N_STORAGE = new InjectionToken<
(lang: TuiLanguageName) => Promise<void>
>(() => Promise.resolve())
>('', {
factory: () => () => Promise.resolve(),
})
export const I18N_PROVIDERS = [
tuiLanguageSwitcher(async (language: TuiLanguageName): Promise<unknown> => {

View File

@@ -50,7 +50,6 @@ export * from './tokens/relative-url'
export * from './util/base-64'
export * from './util/convert-ansi'
export * from './util/copy-to-clipboard'
export * from './util/format-progress'
export * from './util/get-new-entries'
export * from './util/get-pkg-id'

View File

@@ -1,16 +1,20 @@
import { inject, Injectable } from '@angular/core'
import { Clipboard } from '@angular/cdk/clipboard'
import { TuiAlertService } from '@taiga-ui/core'
import { copyToClipboard } from '../util/copy-to-clipboard'
import { i18nPipe } from '../i18n/i18n.pipe'
@Injectable({ providedIn: 'root' })
export class CopyService {
private readonly clipboard = inject(Clipboard)
private readonly i18n = inject(i18nPipe)
private readonly alerts = inject(TuiAlertService)
async copy(text: string) {
const success = await copyToClipboard(text)
const success = this.clipboard.copy(text)
const message = success ? 'Copied to clipboard' : 'Failed'
const appearance = success ? 'positive' : 'negative'
this.alerts
.open(success ? 'Copied to clipboard!' : 'Failed to copy to clipboard.')
.subscribe()
this.alerts.open(this.i18n.transform(message), { appearance }).subscribe()
}
}

View File

@@ -17,7 +17,9 @@ export class DownloadHTMLService {
const elem = this.document.createElement('a')
elem.setAttribute(
'href',
'data:text/plain;charset=utf-8,' + encodeURIComponent(html),
URL.createObjectURL(
new Blob([html], { type: 'application/octet-stream' }),
),
)
elem.setAttribute('download', filename)
elem.style.display = 'none'

View File

@@ -16,7 +16,6 @@ import {
HttpAngularOptions,
HttpOptions,
LocalHttpResponse,
Method,
} from '../types/http.types'
import { RPCResponse, RPCOptions } from '../types/rpc.types'
import { RELATIVE_URL } from '../tokens/relative-url'
@@ -42,7 +41,7 @@ export class HttpService {
const { method, headers, params, timeout } = opts
return this.httpRequest<RPCResponse<T>>({
method: Method.POST,
method: 'POST',
url: fullUrl || this.relativeUrl,
headers,
body: { method, params },
@@ -73,7 +72,7 @@ export class HttpService {
}
let req: Observable<LocalHttpResponse<T>>
if (method === Method.GET) {
if (method === 'GET') {
req = this.http.get(url, options as any) as any
} else {
req = this.http.post(url, body, options as any) as any

View File

@@ -1,9 +1,6 @@
import { HttpHeaders, HttpResponse } from '@angular/common/http'
export enum Method {
GET = 'GET',
POST = 'POST',
}
export type Method = 'GET' | 'POST'
type ParamPrimitive = string | number | boolean

View File

@@ -1,19 +0,0 @@
export async function copyToClipboard(str: string): Promise<boolean> {
if (window.isSecureContext) {
return navigator.clipboard
.writeText(str)
.then(() => true)
.catch(() => false)
}
const el = document.createElement('textarea')
el.value = str
el.setAttribute('readonly', '')
el.style.position = 'absolute'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
const didCopy = document.execCommand('copy')
document.body.removeChild(el)
return didCopy
}

View File

@@ -12,8 +12,7 @@ import {
ValidatorFn,
Validators,
} from '@angular/forms'
import { DialogService, ErrorService } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { ErrorService } from '@start9labs/shared'
import { tuiMarkControlAsTouchedAndValidate, TuiValidator } from '@taiga-ui/cdk'
import {
TuiAlertService,

View File

@@ -10,11 +10,11 @@ export class AuthService {
private readonly storage = inject(WA_LOCAL_STORAGE)
private readonly effect = effect(() => {
if (this.authenticated()) {
this.storage.setItem(KEY, JSON.stringify(true))
this.storage?.setItem(KEY, JSON.stringify(true))
} else {
this.storage.removeItem(KEY)
this.storage?.removeItem(KEY)
}
})
readonly authenticated = signal(Boolean(this.storage.getItem(KEY)))
readonly authenticated = signal(Boolean(this.storage?.getItem(KEY)))
}

View File

@@ -16,6 +16,7 @@ import {
VERSION,
WorkspaceConfig,
} from '@start9labs/shared'
import { tuiObfuscateOptionsProvider } from '@taiga-ui/cdk'
import {
TUI_DATE_FORMAT,
TUI_DIALOGS_CLOSE,
@@ -30,7 +31,7 @@ import {
TUI_DATE_VALUE_TRANSFORMER,
} from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, of, pairwise } from 'rxjs'
import { filter, identity, of, pairwise } from 'rxjs'
import { ConfigService } from 'src/app/services/config.service'
import {
PATCH_CACHE,
@@ -133,4 +134,10 @@ export const APP_PROVIDERS = [
provide: VERSION,
useFactory: () => inject(ConfigService).version,
},
tuiObfuscateOptionsProvider({
recipes: {
mask: ({ length }) => '•'.repeat(length),
none: identity,
},
}),
]

View File

@@ -1,9 +1,9 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectorRef,
Component,
DestroyRef,
forwardRef,
HostBinding,
inject,
Input,
} from '@angular/core'
@@ -15,17 +15,13 @@ import {
} from '@angular/forms'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { IST } from '@start9labs/start-sdk'
import { TuiAnimated } from '@taiga-ui/cdk'
import {
TUI_ANIMATIONS_SPEED,
TuiButton,
TuiError,
tuiFadeIn,
tuiHeightCollapse,
TuiIcon,
TuiLink,
tuiParentStop,
TuiTextfield,
tuiToAnimationOptions,
} from '@taiga-ui/core'
import { TuiFieldErrorPipe, TuiTooltip } from '@taiga-ui/kit'
import { filter } from 'rxjs'
@@ -57,40 +53,40 @@ import { FormObjectComponent } from './object.component'
</div>
<tui-error [error]="order | tuiFieldError | async" />
@for (item of array.control.controls; track item) {
@if (spec.spec.type === 'object') {
<form-object
class="object"
[class.object_open]="!!open.get(item)"
[formGroup]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
[open]="!!open.get(item)"
(openChange)="open.set(item, $event)"
>
{{ item.value | mustache: $any(spec.spec).displayAs }}
<button
tuiIconButton
type="button"
class="remove"
iconStart="@tui.trash"
appearance="icon"
size="m"
title="Remove"
(click.stop)="removeAt($index)"
></button>
</form-object>
} @else {
<form-control
class="control"
tuiTextfieldSize="m"
[formControl]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
(remove)="removeAt($index)"
/>
}
<div tuiAnimated class="control">
<div>
@if (spec.spec.type === 'object') {
<form-object
class="object"
[class.object_open]="!!open.get(item)"
[formGroup]="$any(item)"
[spec]="$any(spec.spec)"
[open]="!!open.get(item)"
(openChange)="open.set(item, $event)"
>
{{ item.value | mustache: $any(spec.spec).displayAs }}
<button
tuiIconButton
type="button"
class="remove"
iconStart="@tui.trash"
appearance="icon"
size="m"
title="Remove"
(click.stop)="removeAt($index)"
></button>
</form-object>
} @else {
<form-control
class="array"
tuiTextfieldSize="m"
[formControl]="$any(item)"
[spec]="$any(spec.spec)"
(remove)="removeAt($index)"
/>
}
</div>
</div>
}
`,
styles: `
@@ -145,11 +141,23 @@ import { FormObjectComponent } from './object.component'
}
.control {
display: block;
margin: 0.5rem 0;
display: grid;
form-control {
display: block;
margin: 0.5rem 0;
}
> * {
overflow: hidden;
}
&.tui-enter,
&.tui-leave {
animation-name: tuiFade, tuiCollapse;
}
}
`,
animations: [tuiFadeIn, tuiHeightCollapse, tuiParentStop],
hostDirectives: [ControlDirective],
imports: [
AsyncPipe,
@@ -166,14 +174,13 @@ import { FormObjectComponent } from './object.component'
MustachePipe,
FormControlComponent,
forwardRef(() => FormObjectComponent),
TuiAnimated,
],
})
export class FormArrayComponent {
@Input({ required: true })
spec!: IST.ValueSpecList
@HostBinding('@tuiParentStop')
readonly animation = tuiToAnimationOptions(inject(TUI_ANIMATIONS_SPEED))
readonly order = ERRORS
readonly array = inject(FormArrayName)
readonly open = new Map<AbstractControl, boolean>()
@@ -181,6 +188,7 @@ export class FormArrayComponent {
private warned = false
private readonly formService = inject(FormService)
private readonly destroyRef = inject(DestroyRef)
private readonly cdr = inject(ChangeDetectorRef)
private readonly dialog = inject(DialogService)
get canAdd(): boolean {
@@ -226,5 +234,6 @@ export class FormArrayComponent {
private addItem() {
this.array.control.insert(0, this.formService.getListItem(this.spec))
this.open.set(this.array.control.at(0), true)
this.cdr.markForCheck()
}
}

View File

@@ -12,10 +12,9 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
import { IST } from '@start9labs/start-sdk'
import { tuiPure, TuiValueChanges } from '@taiga-ui/cdk'
import { TuiValueChanges } from '@taiga-ui/cdk'
import { TuiElasticContainer } from '@taiga-ui/kit'
import { FormService } from 'src/app/services/form.service'
import { FormControlComponent } from './control.component'
import { FormGroupComponent } from './group.component'
@@ -73,7 +72,6 @@ export class FormUnionComponent implements OnChanges {
}
// OTHER?
@tuiPure
onUnion(union: string) {
this.spec.others = this.spec.others || {}
this.spec.others[this.union] = this.form.control.controls['value']?.value
@@ -84,9 +82,7 @@ export class FormUnionComponent implements OnChanges {
[],
this.spec.others[union],
),
{
emitEvent: false,
},
{ emitEvent: false },
)
}

View File

@@ -6,7 +6,6 @@ import {
TUI_LAST_DAY,
TuiDay,
TuiMapperPipe,
tuiPure,
TuiTime,
} from '@taiga-ui/cdk'
import { TuiIcon, TuiTextfield } from '@taiga-ui/core'
@@ -44,7 +43,7 @@ import { HintPipe } from '../pipes/hint.pipe'
[invalid]="control.invalid()"
[readOnly]="readOnly"
[disabled]="!!spec.disabled"
[ngModel]="getTime(value)"
[ngModel]="value | tuiMapper: getTime"
(ngModelChange)="value = $event?.toString() || null"
(blur)="control.onTouched()"
/>
@@ -128,7 +127,6 @@ export class FormDatetimeComponent extends Control<
readonly min = TUI_FIRST_DAY
readonly max = TUI_LAST_DAY
@tuiPure
getTime(value: string | null) {
return value ? TuiTime.fromString(value) : null
}

View File

@@ -4,15 +4,14 @@ import { invert } from '@start9labs/shared'
import { IST } from '@start9labs/start-sdk'
import { tuiPure } from '@taiga-ui/cdk'
import { TuiIcon, TuiTextfield } from '@taiga-ui/core'
import { TuiMultiSelect, TuiTooltip } from '@taiga-ui/kit'
import { TuiChevron, TuiMultiSelect, TuiTooltip } from '@taiga-ui/kit'
import { Control } from './control'
import { HintPipe } from '../pipes/hint.pipe'
@Component({
selector: 'form-multiselect',
template: `
<tui-textfield multi [disabledItemHandler]="disabledItemHandler">
<tui-textfield multi tuiChevron [disabledItemHandler]="disabledItemHandler">
@if (spec.name) {
<label tuiLabel>{{ spec.name }}</label>
}
@@ -43,6 +42,7 @@ import { HintPipe } from '../pipes/hint.pipe'
TuiIcon,
TuiTooltip,
HintPipe,
TuiChevron,
],
})
export class FormMultiselectComponent extends Control<

View File

@@ -5,7 +5,7 @@ import { IST } from '@start9labs/start-sdk'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiDataList, TuiIcon, TuiTextfield } from '@taiga-ui/core'
import {
TuiDataListWrapper,
TuiChevron,
TuiFluidTypography,
tuiFluidTypographyOptionsProvider,
TuiSelect,
@@ -19,6 +19,7 @@ import { HintPipe } from '../pipes/hint.pipe'
selector: 'form-select',
template: `
<tui-textfield
tuiChevron
[tuiTextfieldCleaner]="false"
[disabledItemHandler]="disabledItemHandler"
(tuiActiveZoneChange)="!$event && control.onTouched()"
@@ -76,6 +77,7 @@ import { HintPipe } from '../pipes/hint.pipe'
TuiIcon,
TuiTooltip,
HintPipe,
TuiChevron,
],
})
export class FormSelectComponent extends Control<IST.ValueSpecSelect, string> {

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
import { IST, utils } from '@start9labs/start-sdk'
import { tuiInjectElement } from '@taiga-ui/cdk'
import { TuiButton, TuiIcon, TuiTextfield } from '@taiga-ui/core'
@@ -53,7 +54,9 @@ import { HintPipe } from '../pipes/hint.pipe'
size="xs"
[iconStart]="masked ? '@tui.eye' : '@tui.eye-off'"
(click)="masked = !masked"
></button>
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
<button
tuiIconButton
@@ -63,6 +66,7 @@ import { HintPipe } from '../pipes/hint.pipe'
size="xs"
title="Remove"
class="remove"
(pointerdown.prevent)="(0)"
(click)="remove()"
></button>
@if (spec | hint; as hint) {
@@ -76,7 +80,7 @@ import { HintPipe } from '../pipes/hint.pipe'
order: 1;
}
:host-context(form-array > form-control > :host) .remove {
:host-context(form-control.array > :host) .remove {
display: flex;
}
`,
@@ -87,6 +91,7 @@ import { HintPipe } from '../pipes/hint.pipe'
TuiIcon,
TuiTooltip,
HintPipe,
i18nPipe,
],
})
export class FormTextComponent extends Control<IST.ValueSpecText, string> {

View File

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { CopyService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiButton, TuiHint, TuiTitle } from '@taiga-ui/core'
import { TuiFade } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
@@ -35,14 +35,27 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
</div>
<div tuiCell>
<div tuiTitle>
<strong>{{ 'CA fingerprint' | i18n }}</strong>
<strong>{{ 'Root CA' | i18n }}</strong>
<div tuiSubtitle tuiFade>{{ server.caFingerprint }}</div>
</div>
<a
tuiIconButton
download
appearance="icon"
iconStart="@tui.download"
href="/static/local-root-ca.crt"
[tuiHint]="'Download' | i18n"
tuiHintDirection="bottom"
>
{{ 'Download' | i18n }}
</a>
<button
tuiIconButton
appearance="icon"
iconStart="@tui.copy"
(click)="copyService.copy(server.caFingerprint)"
[tuiHint]="'Copy fingerprint' | i18n"
tuiHintDirection="bottom"
>
{{ 'Copy' | i18n }}
</button>
@@ -65,7 +78,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
`,
styles: '[tuiCell] { padding-inline: 0; white-space: nowrap }',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiTitle, TuiButton, TuiCell, i18nPipe, TuiFade],
imports: [TuiTitle, TuiButton, TuiCell, i18nPipe, TuiFade, TuiHint],
})
export class AboutComponent {
readonly copyService = inject(CopyService)

View File

@@ -1,15 +1,7 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { RouterLink, RouterLinkActive } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import {
TUI_ANIMATIONS_SPEED,
tuiFadeIn,
TuiHint,
TuiIcon,
tuiScaleIn,
tuiToAnimationOptions,
tuiWidthCollapse,
} from '@taiga-ui/core'
import { TuiHint, TuiIcon } from '@taiga-ui/core'
import { TuiBadgedContent, TuiBadgeNotification } from '@taiga-ui/kit'
import { getMenu } from 'src/app/utils/system-utilities'
@@ -27,12 +19,7 @@ import { getMenu } from 'src/app/utils/system-utilities'
[class.link_system]="item.routerLink === 'system'"
[tuiHint]="rla.isActive ? '' : (item.name | i18n)"
>
<tui-badged-content
[style.--tui-radius.%]="50"
[@tuiFadeIn]="animation"
[@tuiWidthCollapse]="animation"
[@tuiScaleIn]="animation"
>
<tui-badged-content [style.--tui-radius.%]="50">
@if (item.badge(); as badge) {
<tui-badge-notification tuiSlot="top" size="s">
{{ badge }}
@@ -175,7 +162,6 @@ import { getMenu } from 'src/app/utils/system-utilities'
display: none;
}
`,
animations: [tuiFadeIn, tuiWidthCollapse, tuiScaleIn],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiBadgeNotification,
@@ -188,6 +174,5 @@ import { getMenu } from 'src/app/utils/system-utilities'
],
})
export class HeaderNavigationComponent {
readonly animation = tuiToAnimationOptions(inject(TUI_ANIMATIONS_SPEED))
readonly utils = getMenu()
}

View File

@@ -3,36 +3,39 @@ import {
Component,
inject,
input,
output,
signal,
} from '@angular/core'
import {
CopyService,
DialogService,
i18nKey,
i18nPipe,
} from '@start9labs/shared'
import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
tuiButtonOptionsProvider,
TuiDataList,
TuiDropdown,
TuiIcon,
TuiTextfield,
} from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { InterfaceComponent } from '../interface.component'
import { InterfaceAddressItemComponent } from './item.component'
@Component({
selector: 'td[actions]',
template: `
<div class="desktop">
<button tuiIconButton appearance="flat-grayscale" (click)="viewDetails()">
{{ 'Address details' | i18n }}
<tui-icon class="info" icon="@tui.info" background="@tui.info-filled" />
</button>
@if (interface.value()?.type === 'ui') {
@if (interface.address().masked) {
<button
tuiIconButton
appearance="flat-grayscale"
[iconStart]="
interface.currentlyMasked() ? '@tui.eye' : '@tui.eye-off'
"
(click)="interface.currentlyMasked.set(!interface.currentlyMasked())"
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
@if (interface.address().ui) {
<a
tuiIconButton
appearance="flat-grayscale"
@@ -67,15 +70,12 @@ import { InterfaceComponent } from '../interface.component'
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open ? 'hover' : null"
[tuiAppearanceState]="open() ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'Actions' | i18n }}
<tui-data-list *tuiTextfieldDropdown="let close">
<button tuiOption new iconStart="@tui.info" (click)="viewDetails()">
{{ 'Address details' | i18n }}
</button>
@if (interface.value()?.type === 'ui') {
<tui-data-list *tuiTextfieldDropdown (click)="open.set(false)">
@if (interface.address().ui) {
<a
tuiOption
new
@@ -87,6 +87,18 @@ import { InterfaceComponent } from '../interface.component'
{{ 'Open' | i18n }}
</a>
}
@if (interface.address().masked) {
<button
tuiOption
new
iconStart="@tui.eye"
(click)="
interface.currentlyMasked.set(!interface.currentlyMasked())
"
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
<button tuiOption new iconStart="@tui.qr-code" (click)="showQR()">
{{ 'Show QR' | i18n }}
</button>
@@ -94,7 +106,7 @@ import { InterfaceComponent } from '../interface.component'
tuiOption
new
iconStart="@tui.copy"
(click)="copyService.copy(href()); close()"
(click)="copyService.copy(href())"
>
{{ 'Copy URL' | i18n }}
</button>
@@ -105,24 +117,12 @@ import { InterfaceComponent } from '../interface.component'
styles: `
:host {
text-align: right;
grid-area: 1 / 2 / 4 / 3;
grid-area: 1/4/4/4;
width: fit-content;
place-content: center;
white-space: nowrap;
}
:host-context(.uncommon-hidden) .desktop {
height: 0;
visibility: hidden;
}
.info {
background: var(--tui-status-info);
&::after {
mask-size: 1.5rem;
}
}
.mobile {
display: none;
}
@@ -136,15 +136,19 @@ import { InterfaceComponent } from '../interface.component'
display: block;
}
}
:host-context(tbody.uncommon-hidden) {
.desktop {
height: 0;
visibility: hidden;
}
.mobile {
display: none;
}
}
`,
imports: [
TuiButton,
TuiDropdown,
TuiDataList,
i18nPipe,
TuiTextfield,
TuiIcon,
],
imports: [TuiButton, TuiDropdown, TuiDataList, i18nPipe, TuiTextfield],
providers: [tuiButtonOptionsProvider({ appearance: 'icon' })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -152,27 +156,13 @@ export class AddressActionsComponent {
readonly isMobile = inject(TUI_IS_MOBILE)
readonly dialog = inject(DialogService)
readonly copyService = inject(CopyService)
readonly interface = inject(InterfaceComponent)
readonly interface = inject(InterfaceAddressItemComponent)
readonly open = signal(false)
readonly href = input.required<string>()
readonly bullets = input.required<string[]>()
readonly disabled = input.required<boolean>()
open = false
viewDetails() {
this.dialog
.openAlert(
`<ul>${this.bullets()
.map(b => `<li>${b}</li>`)
.join('')}</ul>` as i18nKey,
{
label: 'About this address' as i18nKey,
},
)
.subscribe()
}
showQR() {
this.dialog
.openComponent(new PolymorpheusComponent(QRModal), {

View File

@@ -15,12 +15,13 @@ import { InterfaceAddressItemComponent } from './item.component'
<header>{{ 'Addresses' | i18n }}</header>
<tui-elastic-container>
<table [appTable]="['Type', 'Access', 'Gateway', 'URL', null]">
<th [style.width.rem]="2"></th>
@for (address of addresses()?.common; track $index) {
<tr [address]="address" [isRunning]="isRunning()"></tr>
} @empty {
@if (addresses()) {
<tr>
<td colspan="5">
<td colspan="6">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
@@ -39,7 +40,7 @@ import { InterfaceAddressItemComponent } from './item.component'
<tbody [class.uncommon-hidden]="!uncommon">
@if (addresses()?.uncommon?.length && uncommon) {
<tr [style.background]="'var(--tui-background-neutral-1)'">
<td colspan="5"></td>
<td colspan="6"></td>
</tr>
}
@for (address of addresses()?.uncommon; track $index) {
@@ -66,6 +67,20 @@ import { InterfaceAddressItemComponent } from './item.component'
</tui-elastic-container>
`,
styles: `
:host ::ng-deep {
th:nth-child(2) {
width: 5rem;
}
th:nth-child(3) {
width: 4rem;
}
th:nth-child(4) {
width: 17rem;
}
}
.g-table:has(caption) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
@@ -76,6 +91,13 @@ import { InterfaceAddressItemComponent } from './item.component'
border-top-left-radius: 0;
border-top-right-radius: 0;
}
:host-context(tui-root._mobile) {
[tuiButton] {
border-radius: var(--tui-radius-xs);
margin-block-end: 0.75rem;
}
}
`,
host: { class: 'g-card' },
imports: [

View File

@@ -1,13 +1,38 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
signal,
} from '@angular/core'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { TuiObfuscatePipe } from '@taiga-ui/cdk'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { DisplayAddress } from '../interface.service'
import { AddressActionsComponent } from './actions.component'
import { TuiBadge } from '@taiga-ui/kit'
@Component({
selector: 'tr[address]',
template: `
@if (address(); as address) {
<td [style.padding-inline-end]="0">
<div class="wrapper">
<button
tuiIconButton
appearance="flat-grayscale"
(click)="viewDetails()"
>
{{ 'Address details' | i18n }}
<tui-icon
class="info"
icon="@tui.info"
background="@tui.info-filled"
/>
</button>
</div>
</td>
<td>
<div class="wrapper">{{ address.type }}</div>
</td>
@@ -26,13 +51,18 @@ import { TuiBadge } from '@taiga-ui/kit'
}
</div>
</td>
<td [style.order]="-1">
<td [style.grid-area]="'1 / 1 / 1 / 3'">
<div class="wrapper" [title]="address.gatewayName">
{{ address.gatewayName || '-' }}
</div>
</td>
<td>
<div class="wrapper" [title]="address.url">{{ address.url }}</div>
<td [style.grid-area]="'3 / 1 / 3 / 3'">
<div
class="wrapper"
[title]="address.masked && currentlyMasked() ? '' : address.url"
>
{{ address.url | tuiObfuscate: recipe() }}
</div>
</td>
<td
actions
@@ -46,20 +76,30 @@ import { TuiBadge } from '@taiga-ui/kit'
styles: `
:host {
white-space: nowrap;
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
td:last-child {
padding-inline-start: 0;
}
}
.info {
background: var(--tui-status-info);
&::after {
mask-size: 1.5rem;
}
}
:host-context(.uncommon-hidden) {
.wrapper {
height: 0;
visibility: hidden;
}
td {
padding-block: 0;
td,
& {
padding-block: 0 !important;
border: hidden;
}
}
@@ -76,23 +116,50 @@ import { TuiBadge } from '@taiga-ui/kit'
:host-context(tui-root._mobile) {
td {
width: auto !important;
align-content: center;
}
td:first-child {
display: none;
grid-area: 1 / 3 / 4 / 3;
}
td:nth-child(2) {
font: var(--tui-font-text-m);
font-weight: bold;
color: var(--tui-text-primary);
padding-inline-end: 0.5rem;
}
}
`,
imports: [i18nPipe, AddressActionsComponent, TuiBadge],
imports: [
i18nPipe,
AddressActionsComponent,
TuiBadge,
TuiObfuscatePipe,
TuiButton,
TuiIcon,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressItemComponent {
private readonly dialogs = inject(DialogService)
readonly address = input.required<DisplayAddress>()
readonly isRunning = input.required<boolean>()
readonly currentlyMasked = signal(true)
readonly recipe = computed(() =>
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
)
viewDetails() {
this.dialogs
.openAlert(
`<ul>${this.address()
.bullets.map(b => `<li>${b}</li>`)
.join('')}</ul>` as i18nKey,
{ label: 'About this address' as i18nKey },
)
.subscribe()
}
}

View File

@@ -12,7 +12,10 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component'
template: `
<div>
<section [gateways]="value()?.gateways"></section>
<section [publicDomains]="value()?.publicDomains"></section>
<section
[publicDomains]="value()?.publicDomains"
[addSsl]="value()?.addSsl || false"
></section>
<section [torDomains]="value()?.torDomains"></section>
<section [privateDomains]="value()?.privateDomains"></section>
</div>

View File

@@ -10,6 +10,8 @@ type AddressWithInfo = {
info: T.HostnameInfo
gateway?: GatewayPlus
showSsl: boolean
masked: boolean
ui: boolean
}
function cmpWithRankedPredicates<T extends AddressWithInfo>(
@@ -130,6 +132,9 @@ export class InterfaceService {
if (!hostnamesInfos.length) return addresses
const masked = serviceInterface.masked
const ui = serviceInterface.type === 'ui'
const allAddressesWithInfo: AddressWithInfo[] = hostnamesInfos.flatMap(
h => {
const { url, sslUrl } = utils.addressHostToUrl(
@@ -143,7 +148,14 @@ export class InterfaceService {
: undefined
const res = []
if (url) {
res.push({ url, info, gateway, showSsl: false })
res.push({
url,
info,
gateway,
showSsl: false,
masked,
ui,
})
}
if (sslUrl) {
res.push({
@@ -151,6 +163,8 @@ export class InterfaceService {
info,
gateway,
showSsl: !!url,
masked,
ui,
})
}
return res
@@ -328,7 +342,7 @@ export class InterfaceService {
}
private toDisplayAddress(
{ info, url, gateway, showSsl }: AddressWithInfo,
{ info, url, gateway, showSsl, masked, ui }: AddressWithInfo,
publicDomains: Record<string, T.PublicDomainConfig>,
): DisplayAddress {
let access: DisplayAddress['access']
@@ -509,6 +523,8 @@ export class InterfaceService {
gatewayName,
type,
bullets,
masked,
ui,
}
}
}
@@ -522,6 +538,7 @@ export type MappedServiceInterface = T.ServiceInterface & {
common: DisplayAddress[]
uncommon: DisplayAddress[]
}
addSsl: boolean
}
export type InterfaceGateway = GatewayPlus & {
@@ -534,4 +551,6 @@ export type DisplayAddress = {
gatewayName: string | null
url: string
bullets: i18nKey[]
masked: boolean
ui: boolean
}

View File

@@ -58,6 +58,7 @@ import { InterfaceComponent } from './interface.component'
iconStart="@tui.trash"
appearance="action-destructive"
(click)="remove(domain)"
[disabled]="!privateDomains()"
>
{{ 'Delete' | i18n }}
</button>

View File

@@ -31,7 +31,8 @@ import { PublicDomain, PublicDomainService } from './pd.service'
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="service.add()"
(click)="service.add(addSsl())"
[disabled]="!publicDomains()"
>
{{ 'Add' | i18n }}
</button>
@@ -44,7 +45,7 @@ import { PublicDomain, PublicDomainService } from './pd.service'
} @else {
<table [appTable]="['Domain', 'Gateway', 'Certificate Authority', null]">
@for (domain of publicDomains(); track $index) {
<tr [publicDomain]="domain"></tr>
<tr [publicDomain]="domain" [addSsl]="addSsl()"></tr>
} @empty {
@for (_ of [0]; track $index) {
<tr>
@@ -79,4 +80,6 @@ export class PublicDomainsComponent {
readonly service = inject(PublicDomainService)
readonly publicDomains = input.required<readonly PublicDomain[] | undefined>()
readonly addSsl = input.required<boolean>()
}

View File

@@ -52,7 +52,7 @@ import { toAuthorityName } from 'src/app/utils/acme'
tuiOption
new
iconStart="@tui.pencil"
(click)="service.edit(publicDomain())"
(click)="service.edit(publicDomain(), addSsl())"
>
{{ 'Edit' | i18n }}
</button>
@@ -107,8 +107,11 @@ export class PublicDomainsItemComponent {
open = false
readonly publicDomain = input.required<PublicDomain>()
readonly addSsl = input.required<boolean>()
readonly authority = computed(() => toAuthorityName(this.publicDomain().acme))
readonly authority = computed(() =>
toAuthorityName(this.publicDomain().acme, this.addSsl()),
)
readonly dnsMessage = computed<i18nKey>(
() =>
`Create one of the DNS records below to cause ${this.publicDomain().fqdn} to resolve to ${this.publicDomain().gateway?.ipInfo.wanIp}` as i18nKey,

View File

@@ -58,7 +58,7 @@ export class PublicDomainService {
),
)
async add() {
async add(addSsl: boolean) {
const addSpec = ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
@@ -69,7 +69,10 @@ export class PublicDomainService {
default: null,
patterns: [utils.Patterns.domain],
}).map(f => f.toLocaleLowerCase()),
...this.gatewayAndAuthoritySpec(),
...this.gatewaySpec(),
...(addSsl
? this.authoritySpec()
: ({} as ReturnType<typeof this.authoritySpec>)),
})
this.formDialog.open(FormComponent, {
@@ -87,9 +90,12 @@ export class PublicDomainService {
})
}
async edit(domain: PublicDomain) {
async edit(domain: PublicDomain, addSsl: boolean) {
const editSpec = ISB.InputSpec.of({
...this.gatewayAndAuthoritySpec(),
...this.gatewaySpec(),
...(addSsl
? this.authoritySpec()
: ({} as ReturnType<typeof this.authoritySpec>)),
})
this.formDialog.open(FormComponent, {
@@ -155,7 +161,7 @@ export class PublicDomainService {
private async save(
fqdn: string,
gatewayId: string,
authority: 'local' | string,
authority?: 'local' | string,
) {
const gateway = this.data()!.gateways.find(g => g.id === gatewayId)!
@@ -163,7 +169,7 @@ export class PublicDomainService {
const params = {
fqdn,
gateway: gatewayId,
acme: authority === 'local' ? null : authority,
acme: !authority || authority === 'local' ? null : authority,
}
try {
let ip: string | null
@@ -225,7 +231,7 @@ export class PublicDomainService {
}
}
private gatewayAndAuthoritySpec() {
private gatewaySpec() {
const data = this.data()!
const gateways = data.gateways.filter(
@@ -251,6 +257,13 @@ export class PublicDomainService {
.filter(g => !g.ipInfo.wanIp || utils.CGNAT.contains(g.ipInfo.wanIp))
.map(g => g.id),
})),
}
}
private authoritySpec() {
const data = this.data()!
return {
authority: ISB.Value.select({
name: this.i18n.transform('Certificate Authority'),
description: this.i18n.transform(

View File

@@ -50,6 +50,7 @@ type OnionForm = {
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
[disabled]="!torDomains()"
>
{{ 'Add' | i18n }}
</button>

View File

@@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common'
import { Component, ElementRef, Input, ViewChild } from '@angular/core'
import {
INTERSECTION_ROOT,
WA_INTERSECTION_ROOT,
WaIntersectionObserver,
} from '@ng-web-apis/intersection-observer'
import { WaMutationObserver } from '@ng-web-apis/mutation-observer'
@@ -31,12 +31,7 @@ import { LogsPipe } from './logs.pipe'
LogsPipe,
i18nPipe,
],
providers: [
{
provide: INTERSECTION_ROOT,
useExisting: ElementRef,
},
],
providers: [{ provide: WA_INTERSECTION_ROOT, useExisting: ElementRef }],
})
export class LogsComponent {
@ViewChild('bottom')

View File

@@ -6,6 +6,7 @@ import {
signal,
viewChild,
} from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { ActivatedRoute, Router } from '@angular/router'
import {
ErrorService,
@@ -14,12 +15,15 @@ import {
LoadingService,
} from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { filter } from 'rxjs'
import { distinctUntilChanged, skip } from 'rxjs/operators'
import {
RR,
ServerNotification,
ServerNotifications,
} from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { BadgeService } from 'src/app/services/badge.service'
import { NotificationService } from 'src/app/services/notification.service'
import { TitleDirective } from 'src/app/services/title.service'
import { NotificationsTableComponent } from './table.component'
@@ -36,13 +40,13 @@ import { NotificationsTableComponent } from './table.component'
iconStart="@tui.trash"
appearance="primary-destructive"
[style.margin]="'0 0.5rem 0 auto'"
[disabled]="!tableNotifications()?.selected()?.length"
[disabled]="!table()?.selected()?.length"
(click)="remove(notifications() || [])"
>
{{ 'Delete selected' | i18n }}
</button>
</header>
<div #table [notifications]="notifications()"></div>
<div [notifications]="notifications()"></div>
</section>
`,
styles: `
@@ -64,20 +68,26 @@ export default class NotificationsComponent implements OnInit {
readonly errorService = inject(ErrorService)
readonly notifications = signal<ServerNotifications | null>(null)
protected tableNotifications =
viewChild<NotificationsTableComponent<ServerNotification<number>>>('table')
protected readonly table = viewChild<
NotificationsTableComponent<ServerNotification<number>>
>(NotificationsTableComponent)
protected readonly badge = inject(BadgeService)
.getCount('notifications')
.pipe(
distinctUntilChanged(),
filter(Boolean),
skip(1),
takeUntilDestroyed(),
)
.subscribe(() => this.init())
ngOnInit() {
this.route.queryParams.subscribe(params => {
this.router.navigate([], { relativeTo: this.route, queryParams: {} })
if (isEmptyObject(params)) {
this.getMore({}).then(() => {
const latest = this.notifications()?.at(0)
if (latest) {
this.service.markSeenAll(latest.id)
}
})
this.init()
}
})
}
@@ -93,7 +103,7 @@ export default class NotificationsComponent implements OnInit {
async remove(all: ServerNotifications) {
const ids =
this.tableNotifications()
this.table()
?.selected()
.map(n => n.id) || []
const loader = this.loader.open('Deleting').subscribe()
@@ -107,4 +117,13 @@ export default class NotificationsComponent implements OnInit {
loader.unsubscribe()
}
}
private init() {
this.getMore({}).then(() => {
const latest = this.notifications()?.at(0)
if (latest) {
this.service.markSeenAll(latest.id)
}
})
}
}

View File

@@ -1,8 +1,9 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
Input,
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@@ -11,15 +12,15 @@ import { TuiIcon, TuiLoader } from '@taiga-ui/core'
@Component({
selector: 'tr[healthCheck]',
template: `
<td>{{ healthCheck.name }}</td>
<td class="name">{{ healthCheck().name }}</td>
<td>
<span>
@if (loading) {
@if (loading()) {
<tui-loader size="m" />
} @else {
<tui-icon [icon]="icon" [style.color]="color" />
<tui-icon [icon]="icon()" [style.color]="color()" />
}
{{ message }}
{{ message() }}
</span>
</td>
`,
@@ -30,17 +31,25 @@ import { TuiIcon, TuiLoader } from '@taiga-ui/core'
gap: 0.5rem;
}
.name {
width: 9.5rem;
}
:host-context(tui-root._mobile) {
display: flex;
flex-direction: column;
td:first-child {
font-weight: bold;
padding-bottom: 0;
}
td {
width: 100%;
td:last-child {
color: var(--tui-text-secondary);
&:first-child {
font-weight: bold;
padding-bottom: 0;
}
&:last-child {
color: var(--tui-text-secondary);
}
}
}
`,
@@ -48,19 +57,17 @@ import { TuiIcon, TuiLoader } from '@taiga-ui/core'
imports: [TuiLoader, TuiIcon],
})
export class ServiceHealthCheckComponent {
@Input({ required: true })
healthCheck!: T.NamedHealthCheckResult
private readonly i18n = inject(i18nPipe)
get loading(): boolean {
const { result } = this.healthCheck
readonly healthCheck = input.required<T.NamedHealthCheckResult>()
return !result || result === 'starting' || result === 'loading'
}
readonly loading = computed(
({ result } = this.healthCheck()) =>
!result || ['starting', 'loading', 'waiting'].includes(result),
)
get icon(): string {
switch (this.healthCheck.result) {
readonly icon = computed(() => {
switch (this.healthCheck().result) {
case 'success':
return '@tui.check'
case 'failure':
@@ -68,10 +75,10 @@ export class ServiceHealthCheckComponent {
default:
return '@tui.minus'
}
}
})
get color(): string {
switch (this.healthCheck.result) {
readonly color = computed(() => {
switch (this.healthCheck().result) {
case 'success':
return 'var(--tui-status-positive)'
case 'failure':
@@ -79,26 +86,30 @@ export class ServiceHealthCheckComponent {
case 'starting':
case 'loading':
return 'var(--tui-background-accent-1)'
// disabled
// disabled and waiting
default:
return 'var(--tui-text-secondary)'
}
}
})
get message(): string {
if (!this.healthCheck.result) {
readonly message = computed(({ result, message } = this.healthCheck()) => {
if (!result) {
return this.i18n.transform('Awaiting result')!
}
switch (this.healthCheck.result) {
switch (result) {
case 'starting':
return this.i18n.transform('Starting')!
case 'success':
return `${this.i18n.transform('Success')}: ${this.healthCheck.message || 'health check passing'}`
return `${this.i18n.transform('Success')}: ${message || 'health check passing'}`
case 'waiting':
return message
? `${this.i18n.transform('Waiting on')} ${message}...`
: `${this.i18n.transform('Waiting')}...`
case 'loading':
case 'failure':
case 'disabled':
return this.healthCheck.message || this.healthCheck.result
return message || result
}
}
})
}

View File

@@ -1,15 +1,12 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
OnChanges,
computed,
input,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import { tuiPure } from '@taiga-ui/cdk'
import { ServiceUptimeComponent } from 'src/app/routes/portal/routes/services/components/uptime.component'
import { ConnectionService } from 'src/app/services/connection.service'
import { PkgDependencyErrors } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
@@ -19,17 +16,17 @@ import { StatusComponent } from './status.component'
selector: 'tr[appService]',
template: `
<td [style.width.rem]="3" [style.grid-area]="'1 / 1 / 4'">
<img alt="logo" [src]="pkg.icon" />
<img alt="logo" [src]="pkg().icon" />
</td>
<td class="title">
<a [routerLink]="routerLink">{{ manifest.title }}</a>
<a [routerLink]="'/services/' + manifest().id">{{ manifest().title }}</a>
</td>
<td class="status" [style.grid-area]="'3 / 2'">
<app-status [pkg]="pkg" [hasDepErrors]="hasError(depErrors)" />
<app-status [pkg]="pkg()" [hasDepErrors]="hasError()" />
</td>
<td class="version">{{ manifest.version }}</td>
<td class="version">{{ manifest().version }}</td>
<td class="uptime">
@if (pkg.statusInfo.started; as started) {
@if (pkg().statusInfo.started; as started) {
<span>{{ 'Uptime' | i18n }}:</span>
<service-uptime [started]="started" />
} @else {
@@ -143,39 +140,15 @@ import { StatusComponent } from './status.component'
}
}
`,
hostDirectives: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, StatusComponent, ServiceUptimeComponent, i18nPipe],
})
export class ServiceComponent implements OnChanges {
private readonly link = inject(RouterLink)
export class ServiceComponent {
readonly pkg = input.required<PackageDataEntry>()
readonly depErrors = input<PkgDependencyErrors>({})
@Input()
pkg!: PackageDataEntry
@Input()
depErrors?: PkgDependencyErrors
readonly connected$ = inject(ConnectionService)
get installed(): boolean {
return this.pkg.stateInfo.state === 'installed'
}
get manifest() {
return getManifest(this.pkg)
}
get routerLink() {
return `/services/${this.manifest.id}`
}
ngOnChanges() {
this.link.routerLink = this.routerLink
}
@tuiPure
hasError(errors: PkgDependencyErrors = {}): boolean {
return Object.values(errors).some(Boolean)
}
readonly manifest = computed(() => getManifest(this.pkg()))
readonly hasError = computed(() =>
Object.values(this.depErrors()).some(Boolean),
)
}

View File

@@ -60,8 +60,9 @@ import { RouterLink } from '@angular/router'
@for (service of services() | tuiTableSort; track $index) {
<tr
appService
[routerLink]="'/services/' + (service | toManifest)?.id"
[pkg]="service"
[depErrors]="errors()?.[(service | toManifest).id]"
[depErrors]="errors()?.[(service | toManifest).id] || {}"
></tr>
} @empty {
@for (_ of ['', '']; track $index) {

View File

@@ -1,5 +1,5 @@
import { AsyncPipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { Component, inject, signal } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
DialogService,
getErrorMessage,
@@ -17,7 +17,7 @@ import { injectContext } from '@taiga-ui/polymorpheus'
import * as json from 'fast-json-patch'
import { compare } from 'fast-json-patch'
import { PatchDB } from 'patch-db-client'
import { catchError, defer, EMPTY, endWith, firstValueFrom, map } from 'rxjs'
import { catchError, EMPTY, endWith, firstValueFrom, from, map } from 'rxjs'
import {
ActionButton,
FormComponent,
@@ -50,13 +50,12 @@ export type PackageActionData = {
<img [src]="pkgInfo.icon" alt="" />
<h4>{{ pkgInfo.title }}</h4>
</div>
@if (res$ | async; as res) {
@if (error) {
<tui-notification appearance="negative">
<div [innerHTML]="error"></div>
</tui-notification>
}
@if (error()) {
<tui-notification appearance="negative">
<div [innerHTML]="error()"></div>
</tui-notification>
}
@if (res(); as res) {
@if (warning) {
<tui-notification appearance="warning">
<div [innerHTML]="warning"></div>
@@ -85,7 +84,7 @@ export type PackageActionData = {
{{ 'Reset defaults' | i18n }}
</button>
</app-form>
} @else {
} @else if (!error()) {
<tui-loader size="l" textContent="loading" />
}
`,
@@ -111,7 +110,6 @@ export type PackageActionData = {
}
`,
imports: [
AsyncPipe,
TuiNotification,
TuiLoader,
TuiButton,
@@ -143,38 +141,39 @@ export class ActionInputModal {
},
]
error = ''
readonly error = signal('')
readonly res = toSignal(
from(
this.api.getActionInput({
packageId: this.pkgInfo.id,
actionId: this.actionId,
}),
).pipe(
map(res => {
console.warn('MAP', res)
const originalValue = res.value || {}
this.eventId = res.eventId
res$ = defer(() =>
this.api.getActionInput({
packageId: this.pkgInfo.id,
actionId: this.actionId,
}),
).pipe(
map(res => {
console.warn('MAP', res)
const originalValue = res.value || {}
this.eventId = res.eventId
return {
spec: res.spec,
originalValue,
operations: this.requestInfo?.input
? compare(
JSON.parse(JSON.stringify(originalValue)),
utils.deepMerge(
return {
spec: res.spec,
originalValue,
operations: this.requestInfo?.input
? compare(
JSON.parse(JSON.stringify(originalValue)),
this.requestInfo.input.value,
) as object,
)
: null,
}
}),
catchError(e => {
console.error('catchError', e)
this.error = String(getErrorMessage(e))
return EMPTY
}),
utils.deepMerge(
JSON.parse(JSON.stringify(originalValue)),
this.requestInfo.input.value,
) as object,
)
: null,
}
}),
catchError(e => {
console.error('catchError', e)
this.error.set(String(getErrorMessage(e)))
return EMPTY
}),
),
)
async execute(input: object) {

View File

@@ -2,13 +2,12 @@ import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
ElementRef,
inject,
Input,
ViewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiTextfield, TuiTextfieldDirective } from '@taiga-ui/core'
import { CopyService, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiTextfield } from '@taiga-ui/core'
import { QrCodeComponent } from 'ng-qrcode'
import { SingleResult } from './types'
@@ -48,7 +47,7 @@ import { SingleResult } from './types'
tabindex="-1"
iconStart="@tui.copy"
[style.pointer-events]="'auto'"
(click)="copy()"
(click)="copy.copy(single.value)"
>
{{ 'Copy' | i18n }}
</button>
@@ -96,25 +95,10 @@ import { SingleResult } from './types'
],
})
export class ActionSuccessSingleComponent {
@ViewChild(TuiTextfieldDirective, { read: ElementRef })
private readonly input!: ElementRef<HTMLInputElement>
readonly copy = inject(CopyService)
@Input()
single!: SingleResult
masked = true
copy() {
const el = this.input.nativeElement
if (!el) {
return
}
el.type = 'text'
el.focus()
el.select()
el.ownerDocument.execCommand('copy')
el.type = this.masked && this.single.masked ? 'password' : 'text'
}
}

View File

@@ -58,7 +58,11 @@ export default class ServiceAboutRoute {
private readonly markdown = inject(DialogService).openComponent(MARKDOWN, {
label: 'License',
size: 'l',
data: from(inject(ApiService).getStaticInstalled(this.pkgId, 'LICENSE.md')),
data: from(
inject(ApiService).getStatic(
`/s9pk/installed/${this.pkgId}.s9pk/LICENSE.md`,
),
),
})
readonly groups = toSignal<{ header: i18nKey; items: AdditionalItem[] }[]>(

View File

@@ -142,6 +142,7 @@ export default class ServiceInterfaceRoute {
torDomains: host.onions,
publicDomains: getPublicDomains(host.publicDomains, gateways),
privateDomains: host.privateDomains,
addSsl: !!binding?.options.addSsl,
}
})

View File

@@ -69,7 +69,6 @@ import { BACKUP_RESTORE } from './restore.component'
path="/user-manual/backup-create.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
[textContent]="'View instructions' | i18n"
></a>
} @else {
@@ -83,7 +82,6 @@ import { BACKUP_RESTORE } from './restore.component'
path="/user-manual/backup-restore.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
[textContent]="'View instructions' | i18n"
></a>
}
@@ -127,7 +125,6 @@ import { BACKUP_RESTORE } from './restore.component'
fragment="#network-folder"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
[textContent]="'View instructions' | i18n"
></a>
</section>

View File

@@ -20,7 +20,7 @@ import {
LoadingService,
} from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiContext, TuiStringHandler } from '@taiga-ui/cdk'
import { TuiAnimated, TuiContext, TuiStringHandler } from '@taiga-ui/cdk'
import {
TuiAppearance,
TuiButton,
@@ -168,7 +168,7 @@ import { SystemWipeComponent } from './wipe.component'
</button>
</div>
@if (count > 4) {
<div tuiCell tuiAppearance="outline-grayscale" @tuiScaleIn @tuiFadeIn>
<div tuiCell tuiAppearance="outline-grayscale" tuiAnimated>
<tui-icon icon="@tui.briefcase-medical" />
<span tuiTitle>
<strong>{{ 'Disk Repair' | i18n }}</strong>
@@ -209,10 +209,14 @@ import { SystemWipeComponent } from './wipe.component'
[tuiCell] {
background: var(--tui-background-neutral-1);
}
[tuiAnimated].tui-enter,
[tuiAnimated].tui-leave {
animation-name: tuiFade, tuiScale;
}
`,
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [tuiScaleIn, tuiFadeIn],
imports: [
AsyncPipe,
RouterLink,
@@ -231,6 +235,7 @@ import { SystemWipeComponent } from './wipe.component'
SnekDirective,
TuiBadge,
TuiBadgeNotification,
TuiAnimated,
],
})
export default class SystemGeneralComponent {

View File

@@ -103,6 +103,7 @@ export default class StartOsUiComponent {
torDomains: network.host.onions,
publicDomains: getPublicDomains(network.host.publicDomains, gateways),
privateDomains: network.host.privateDomains,
addSsl: true,
}
})
}

View File

@@ -43,11 +43,11 @@ export const SYSTEM_MENU = [
item: 'Gateways',
link: 'gateways',
},
{
icon: '@tui.award',
item: 'Certificate Authorities',
link: 'authorities',
},
// {
// icon: '@tui.award',
// item: 'Certificate Authorities',
// link: 'authorities',
// },
{
icon: '@tui.globe',
item: 'DNS' as i18nKey,

View File

@@ -71,12 +71,12 @@ export default [
path: 'gateways',
loadComponent: () => import('./routes/gateways/gateways.component'),
},
{
path: 'authorities',
title: titleResolver,
loadComponent: () =>
import('./routes/authorities/authorities.component'),
},
// {
// path: 'authorities',
// title: titleResolver,
// loadComponent: () =>
// import('./routes/authorities/authorities.component'),
// },
{
path: 'dns',
title: titleResolver,

View File

@@ -385,7 +385,7 @@ export namespace Mock {
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.45',
sdkVersion: '0.4.0-beta.46',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -420,7 +420,7 @@ export namespace Mock {
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.45',
sdkVersion: '0.4.0-beta.46',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -465,7 +465,7 @@ export namespace Mock {
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.45',
sdkVersion: '0.4.0-beta.46',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -500,7 +500,7 @@ export namespace Mock {
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.45',
sdkVersion: '0.4.0-beta.46',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -547,7 +547,7 @@ export namespace Mock {
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release to 0.17.5',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.45',
sdkVersion: '0.4.0-beta.46',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -595,7 +595,7 @@ export namespace Mock {
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release to 0.17.4',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.45',
sdkVersion: '0.4.0-beta.46',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -647,7 +647,7 @@ export namespace Mock {
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.45',
sdkVersion: '0.4.0-beta.46',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -682,7 +682,7 @@ export namespace Mock {
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.45',
sdkVersion: '0.4.0-beta.46',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -727,7 +727,7 @@ export namespace Mock {
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release and minor fixes.',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.45',
sdkVersion: '0.4.0-beta.46',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -775,7 +775,7 @@ export namespace Mock {
marketingSite: '',
releaseNotes: 'Upstream release and minor fixes.',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.45',
sdkVersion: '0.4.0-beta.46',
gitHash: 'fakehash',
icon: PROXY_ICON,
sourceVersion: null,

View File

@@ -15,10 +15,7 @@ export abstract class ApiService {
path: 'LICENSE.md',
): Promise<string>
abstract getStaticInstalled(
id: T.PackageId,
path: 'LICENSE.md',
): Promise<string>
abstract getStatic(url: string): Promise<string>
// websocket

View File

@@ -5,7 +5,6 @@ import {
HttpOptions,
HttpService,
isRpcError,
Method,
RpcError,
RPCOptions,
} from '@start9labs/shared'
@@ -37,7 +36,7 @@ export class LiveApiService extends ApiService {
async uploadFile(guid: string, body: Blob): Promise<void> {
await this.httpRequest({
method: Method.POST,
method: 'POST',
body,
url: `/rest/rpc/${guid}`,
timeout: 0,
@@ -53,7 +52,7 @@ export class LiveApiService extends ApiService {
const encodedUrl = encodeURIComponent(pkg.s9pk.url)
return this.httpRequest({
method: Method.GET,
method: 'GET',
url: `/s9pk/proxy/${encodedUrl}/${path}`,
params: {
rootSighash: pkg.s9pk.commitment.rootSighash,
@@ -63,13 +62,10 @@ export class LiveApiService extends ApiService {
})
}
async getStaticInstalled(
id: T.PackageId,
path: 'LICENSE.md',
): Promise<string> {
async getStatic(url: string): Promise<string> {
return this.httpRequest({
method: Method.GET,
url: `/s9pk/installed/${id}.s9pk/${path}`,
method: 'GET',
url,
responseType: 'text',
})
}

View File

@@ -83,10 +83,7 @@ export class MockApiService extends ApiService {
return markdown
}
async getStaticInstalled(
id: T.PackageId,
path: 'LICENSE.md',
): Promise<string> {
async getStatic(url: string): Promise<string> {
await pauseFor(2000)
return markdown
}
@@ -1125,8 +1122,8 @@ export class MockApiService extends ApiService {
},
'p2p-interface': {
name: 'P2P Interface',
result: 'success',
message: null,
result: 'waiting',
message: 'Chain State',
},
'rpc-interface': {
name: 'RPC Interface',

View File

@@ -163,16 +163,3 @@ export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = {
showDots: false,
},
}
export const DependencyRendering: Record<DependencyStatus, StatusRendering> = {
warning: { display: 'Issue', color: 'warning' },
satisfied: { display: 'Satisfied', color: 'success' },
}
export const HealthRendering: Record<T.HealthStatus, StatusRendering> = {
failure: { display: 'Failure', color: 'danger' },
starting: { display: 'Starting', color: 'primary' },
loading: { display: 'Loading', color: 'primary' },
success: { display: 'Healthy', color: 'success' },
disabled: { display: 'Disabled', color: 'dark' },
}

View File

@@ -1,7 +1,12 @@
export function toAuthorityName(url: string | null): string | 'Local Root CA' {
return (
knownAuthorities.find(ca => ca.url === url)?.name || url || 'Local Root CA'
)
export function toAuthorityName(
url: string | null,
addSsl = true,
): string | 'Local Root CA' | '-' {
if (url) {
return knownAuthorities.find(ca => ca.url === url)?.name || url
} else {
return addSsl ? 'Local Root CA' : '-'
}
}
export function toAuthorityUrl(name: string): string {