Compare commits

..

3 Commits

Author SHA1 Message Date
Matt Hill
fe28a812a4 fix ssh, undeprecate wifi 2026-02-10 13:41:54 -07:00
Aiden McClelland
b6262c8e13 Fix PackageInfoShort to handle LocaleString on releaseNotes (#3112)
* Fix PackageInfoShort to handle LocaleString on releaseNotes

* fix: filter by target_version in get_matching_models and pass otherVersions from install

* chore: add exver documentation for ai agents
2026-02-09 19:42:03 +00:00
Matt Hill
ba740a9ee2 Multiple (#3111)
* fix alerts i18n, fix status display, better, remove usb media, hide shutdown for install complete

* trigger chnage detection for localize pipe and round out implementing localize pipe for consistency even though not needed
2026-02-09 12:41:29 -07:00
22 changed files with 370 additions and 155 deletions

View File

@@ -12,7 +12,7 @@
{{ pkg.title }} {{ pkg.title }}
</span> </span>
<span class="detail-description"> <span class="detail-description">
{{ pkg.description.short }} {{ pkg.description.short | localize }}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,12 +1,12 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { SharedPipesModule, TickerComponent } from '@start9labs/shared' import { LocalizePipe, SharedPipesModule, TickerComponent } from '@start9labs/shared'
import { ItemComponent } from './item.component' import { ItemComponent } from './item.component'
@NgModule({ @NgModule({
declarations: [ItemComponent], declarations: [ItemComponent],
exports: [ItemComponent], exports: [ItemComponent],
imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent], imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent, LocalizePipe],
}) })
export class ItemModule {} export class ItemModule {}

View File

@@ -6,7 +6,7 @@ import {
output, output,
} from '@angular/core' } from '@angular/core'
import { MarketplacePkgBase } from '../../types' import { MarketplacePkgBase } from '../../types'
import { CopyService, i18nPipe } from '@start9labs/shared' import { CopyService, i18nPipe, LocalizePipe } from '@start9labs/shared'
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { MarketplaceItemComponent } from './item.component' import { MarketplaceItemComponent } from './item.component'
@@ -71,7 +71,7 @@ import { MarketplaceItemComponent } from './item.component'
<div class="background-border box-shadow-lg shadow-color-light"> <div class="background-border box-shadow-lg shadow-color-light">
<div class="box-container"> <div class="box-container">
<h2 class="additional-detail-title">{{ 'Description' | i18n }}</h2> <h2 class="additional-detail-title">{{ 'Description' | i18n }}</h2>
<p [innerHTML]="pkg().description.long"></p> <p [innerHTML]="pkg().description.long | localize"></p>
</div> </div>
</div> </div>
`, `,
@@ -129,7 +129,7 @@ import { MarketplaceItemComponent } from './item.component'
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MarketplaceItemComponent, DatePipe, i18nPipe], imports: [MarketplaceItemComponent, DatePipe, i18nPipe, LocalizePipe],
}) })
export class MarketplaceAboutComponent { export class MarketplaceAboutComponent {
readonly copyService = inject(CopyService) readonly copyService = inject(CopyService)

View File

@@ -0,0 +1,121 @@
import { Component } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus'
@Component({
standalone: true,
imports: [TuiButton, i18nPipe],
template: `
<div class="animation-container">
<div class="port">
<div class="port-inner"></div>
</div>
<div class="usb-stick">
<div class="usb-connector"></div>
<div class="usb-body"></div>
</div>
</div>
<p>
{{
'Remove USB stick or other installation media from your server' | i18n
}}
</p>
<footer>
<button tuiButton (click)="context.completeWith(true)">
{{ 'Done' | i18n }}
</button>
</footer>
`,
styles: `
:host {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.animation-container {
position: relative;
width: 160px;
height: 69px;
}
.port {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 18px;
background: var(--tui-background-neutral-1);
border: 2px solid var(--tui-border-normal);
border-radius: 2px;
}
.port-inner {
position: absolute;
top: 3px;
left: 3px;
right: 3px;
bottom: 3px;
background: var(--tui-background-neutral-2);
border-radius: 1px;
}
.usb-stick {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
animation: slide-out 2s ease-in-out 0.5s infinite;
left: 32px;
}
.usb-connector {
width: 20px;
height: 12px;
background: var(--tui-text-secondary);
border-radius: 1px;
}
.usb-body {
width: 40px;
height: 20px;
background: var(--tui-status-info);
border-radius: 2px 4px 4px 2px;
}
@keyframes slide-out {
0% {
left: 32px;
opacity: 0;
}
5% {
left: 32px;
opacity: 1;
}
80% {
left: 130px;
opacity: 0;
}
100% {
left: 130px;
opacity: 0;
}
}
p {
margin: 0 0 2rem;
}
footer {
display: flex;
justify-content: center;
}
`,
})
export class RemoveMediaDialog {
protected readonly context = injectContext<TuiDialogContext<boolean>>()
}

View File

@@ -1,4 +1,9 @@
import { ChangeDetectorRef, Component, inject } from '@angular/core' import {
ChangeDetectorRef,
Component,
HostListener,
inject,
} from '@angular/core'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { import {
@@ -21,13 +26,14 @@ import {
import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit' import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { filter } from 'rxjs' import { filter, Subscription } from 'rxjs'
import { ApiService } from '../services/api.service' import { ApiService } from '../services/api.service'
import { StateService } from '../services/state.service' import { StateService } from '../services/state.service'
import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog' import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog'
@Component({ @Component({
template: ` template: `
@if (!shuttingDown) {
<section tuiCardLarge="compact"> <section tuiCardLarge="compact">
<header tuiHeader> <header tuiHeader>
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2> <h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
@@ -132,6 +138,7 @@ import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog
} }
</footer> </footer>
</section> </section>
}
`, `,
styles: ` styles: `
.no-drives { .no-drives {
@@ -176,6 +183,14 @@ export default class DrivesPage {
protected readonly mobile = inject(TUI_IS_MOBILE) protected readonly mobile = inject(TUI_IS_MOBILE)
@HostListener('document:keydown', ['$event'])
onKeydown(event: KeyboardEvent) {
if (event.ctrlKey && event.shiftKey && event.key === 'X') {
event.preventDefault()
this.shutdownServer()
}
}
readonly osDriveTooltip = this.i18n.transform( readonly osDriveTooltip = this.i18n.transform(
'The drive where the StartOS operating system will be installed.', 'The drive where the StartOS operating system will be installed.',
) )
@@ -185,6 +200,8 @@ export default class DrivesPage {
drives: DiskInfo[] = [] drives: DiskInfo[] = []
loading = true loading = true
shuttingDown = false
private dialogSub?: Subscription
selectedOsDrive: DiskInfo | null = null selectedOsDrive: DiskInfo | null = null
selectedDataDrive: DiskInfo | null = null selectedDataDrive: DiskInfo | null = null
preserveData: boolean | null = null preserveData: boolean | null = null
@@ -339,22 +356,18 @@ export default class DrivesPage {
loader.unsubscribe() loader.unsubscribe()
// Show success dialog // Show success dialog
this.dialogs this.dialogSub = this.dialogs
.openConfirm({ .openAlert('StartOS has been installed successfully.', {
label: 'Installation Complete!', label: 'Installation Complete!',
size: 's', size: 's',
data: { dismissible: false,
content: 'StartOS has been installed successfully.', closeable: true,
yes: 'Continue to Setup', data: { button: this.i18n.transform('Continue to Setup') },
no: 'Shutdown',
},
}) })
.subscribe(continueSetup => { .subscribe({
if (continueSetup) { complete: () => {
this.navigateToNextStep(result.attach) this.navigateToNextStep(result.attach)
} else { },
this.shutdownServer()
}
}) })
} catch (e: any) { } catch (e: any) {
loader.unsubscribe() loader.unsubscribe()
@@ -372,10 +385,12 @@ export default class DrivesPage {
} }
private async shutdownServer() { private async shutdownServer() {
this.dialogSub?.unsubscribe()
const loader = this.loader.open('Beginning shutdown').subscribe() const loader = this.loader.open('Beginning shutdown').subscribe()
try { try {
await this.api.shutdown() await this.api.shutdown()
this.shuttingDown = true
} catch (e: any) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)
} finally { } finally {

View File

@@ -6,7 +6,12 @@ import {
ViewChild, ViewChild,
DOCUMENT, DOCUMENT,
} from '@angular/core' } from '@angular/core'
import { DownloadHTMLService, ErrorService, i18nPipe } from '@start9labs/shared' import {
DialogService,
DownloadHTMLService,
ErrorService,
i18nPipe,
} from '@start9labs/shared'
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core' import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit' import { TuiAvatar } from '@taiga-ui/kit'
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout' import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
@@ -14,7 +19,9 @@ import { ApiService } from '../services/api.service'
import { StateService } from '../services/state.service' import { StateService } from '../services/state.service'
import { DocumentationComponent } from '../components/documentation.component' import { DocumentationComponent } from '../components/documentation.component'
import { MatrixComponent } from '../components/matrix.component' import { MatrixComponent } from '../components/matrix.component'
import { RemoveMediaDialog } from '../components/remove-media.dialog'
import { SetupCompleteRes } from '../types' import { SetupCompleteRes } from '../types'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
@Component({ @Component({
template: ` template: `
@@ -29,12 +36,8 @@ import { SetupCompleteRes } from '../types'
@if (!stateService.kiosk) { @if (!stateService.kiosk) {
<span tuiSubtitle> <span tuiSubtitle>
{{ {{
stateService.setupType === 'restore' 'http://start.local was for setup only. It will no longer work.'
? ('You can unplug your backup drive' | i18n) | i18n
: stateService.setupType === 'transfer'
? ('You can unplug your transfer drive' | i18n)
: ('http://start.local was for setup only. It will no longer work.'
| i18n)
}} }}
</span> </span>
} }
@@ -69,14 +72,15 @@ import { SetupCompleteRes } from '../types'
tuiCell="l" tuiCell="l"
[class.disabled]="!stateService.kiosk && !downloaded" [class.disabled]="!stateService.kiosk && !downloaded"
[disabled]="(!stateService.kiosk && !downloaded) || usbRemoved" [disabled]="(!stateService.kiosk && !downloaded) || usbRemoved"
(click)="usbRemoved = true" (click)="removeMedia()"
> >
<tui-avatar appearance="secondary" src="@tui.usb" /> <tui-avatar appearance="secondary" src="@tui.usb" />
<div tuiTitle> <div tuiTitle>
{{ 'USB Removed' | i18n }} {{ 'Remove Installation Media' | i18n }}
<div tuiSubtitle> <div tuiSubtitle>
{{ {{
'Remove the USB installation media from your server' | i18n 'Remove USB stick or other installation media from your server'
| i18n
}} }}
</div> </div>
</div> </div>
@@ -184,6 +188,7 @@ export default class SuccessPage implements AfterViewInit {
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly downloadHtml = inject(DownloadHTMLService) private readonly downloadHtml = inject(DownloadHTMLService)
private readonly dialogs = inject(DialogService)
private readonly i18n = inject(i18nPipe) private readonly i18n = inject(i18nPipe)
readonly stateService = inject(StateService) readonly stateService = inject(StateService)
@@ -225,6 +230,21 @@ export default class SuccessPage implements AfterViewInit {
}) })
} }
removeMedia() {
this.dialogs
.openComponent<boolean>(
new PolymorpheusComponent(RemoveMediaDialog),
{
size: 's',
dismissible: false,
closeable: false,
},
)
.subscribe(() => {
this.usbRemoved = true
})
}
exitKiosk() { exitKiosk() {
this.api.exit() this.api.exit()
} }

View File

@@ -5,7 +5,7 @@ export default {
2: 'Aktualisieren', 2: 'Aktualisieren',
4: 'System', 4: 'System',
5: 'Allgemein', 5: 'Allgemein',
6: 'E-Mail', 6: 'SMTP',
7: 'Sicherung erstellen', 7: 'Sicherung erstellen',
8: 'Sicherung wiederherstellen', 8: 'Sicherung wiederherstellen',
9: 'Zum Login gehen', 9: 'Zum Login gehen',
@@ -100,6 +100,7 @@ export default {
102: 'Verlassen', 102: 'Verlassen',
103: 'Sind Sie sicher?', 103: 'Sind Sie sicher?',
104: 'Neues Netzwerk-Gateway', 104: 'Neues Netzwerk-Gateway',
107: 'Onion-Domains',
108: 'Öffentlich', 108: 'Öffentlich',
109: 'privat', 109: 'privat',
111: 'Keine Onion-Domains', 111: 'Keine Onion-Domains',
@@ -384,8 +385,8 @@ export default {
405: 'Verbunden', 405: 'Verbunden',
406: 'Vergessen', 406: 'Vergessen',
407: 'WiFi-Zugangsdaten', 407: 'WiFi-Zugangsdaten',
408: 'Veraltet', 408: 'Mit verstecktem Netzwerk verbinden',
409: 'Die WLAN-Unterstützung wird in StartOS v0.4.1 entfernt. Wenn Sie keinen Zugriff auf Ethernet haben, können Sie einen WLAN-Extender verwenden, um sich mit dem lokalen Netzwerk zu verbinden und dann Ihren Server über Ethernet an den Extender anschließen. Bitte wenden Sie sich bei Fragen an den Start9-Support.', 409: 'Verbinden mit',
410: 'Bekannte Netzwerke', 410: 'Bekannte Netzwerke',
411: 'Weitere Netzwerke', 411: 'Weitere Netzwerke',
412: 'WiFi ist deaktiviert', 412: 'WiFi ist deaktiviert',
@@ -639,13 +640,11 @@ export default {
667: 'Einrichtung wird gestartet', 667: 'Einrichtung wird gestartet',
670: 'Warten Sie 12 Minuten und aktualisieren Sie die Seite', 670: 'Warten Sie 12 Minuten und aktualisieren Sie die Seite',
672: 'Einrichtung abgeschlossen!', 672: 'Einrichtung abgeschlossen!',
673: 'Sie können Ihr Backup-Laufwerk entfernen',
674: 'Sie können Ihr Übertragungs-Laufwerk entfernen',
675: 'http://start.local war nur für die Einrichtung gedacht. Es funktioniert nicht mehr.', 675: 'http://start.local war nur für die Einrichtung gedacht. Es funktioniert nicht mehr.',
676: 'Adressinformationen herunterladen', 676: 'Adressinformationen herunterladen',
677: 'Enthält die permanente lokale Adresse Ihres Servers und die Root-CA', 677: 'Enthält die permanente lokale Adresse Ihres Servers und die Root-CA',
678: 'USB entfernt', 678: 'Installationsmedium entfernen',
679: 'Entfernen Sie das USB-Installationsmedium aus Ihrem Server', 679: 'Entfernen Sie den USB-Stick oder andere Installationsmedien von Ihrem Server',
680: 'Server neu starten', 680: 'Server neu starten',
681: 'Warten, bis der Server wieder online ist', 681: 'Warten, bis der Server wieder online ist',
682: 'Server ist wieder online', 682: 'Server ist wieder online',

View File

@@ -4,7 +4,7 @@ export const ENGLISH: Record<string, number> = {
'Update': 2, // verb 'Update': 2, // verb
'System': 4, // as in, system preferences 'System': 4, // as in, system preferences
'General': 5, // as in, general settings 'General': 5, // as in, general settings
'Email': 6, 'SMTP': 6,
'Create Backup': 7, // create a backup 'Create Backup': 7, // create a backup
'Restore Backup': 8, // restore from backup 'Restore Backup': 8, // restore from backup
'Go to login': 9, 'Go to login': 9,
@@ -99,6 +99,7 @@ export const ENGLISH: Record<string, number> = {
'Leave': 102, 'Leave': 102,
'Are you sure?': 103, 'Are you sure?': 103,
'New gateway': 104, // as in, a network gateway 'New gateway': 104, // as in, a network gateway
'Tor Domains': 107,
'public': 108, 'public': 108,
'private': 109, 'private': 109,
'No Tor domains': 111, 'No Tor domains': 111,
@@ -383,8 +384,8 @@ export const ENGLISH: Record<string, number> = {
'Connected': 405, 'Connected': 405,
'Forget': 406, // as in, delete or remove 'Forget': 406, // as in, delete or remove
'WiFi Credentials': 407, 'WiFi Credentials': 407,
'Deprecated': 408, 'Connect to hidden network': 408,
'WiFi support will be removed in StartOS v0.4.1. If you do not have access to Ethernet, you can use a WiFi extender to connect to the local network, then connect your server to the extender via Ethernet. Please contact Start9 support with any questions or concerns.': 409, 'Connect to': 409, // followed by a network name, e.g. "Connect to MyWiFi?"
'Known Networks': 410, 'Known Networks': 410,
'Other Networks': 411, 'Other Networks': 411,
'WiFi is disabled': 412, 'WiFi is disabled': 412,
@@ -639,13 +640,11 @@ export const ENGLISH: Record<string, number> = {
'Starting setup': 667, 'Starting setup': 667,
'Wait 1-2 minutes and refresh the page': 670, 'Wait 1-2 minutes and refresh the page': 670,
'Setup Complete!': 672, 'Setup Complete!': 672,
'You can unplug your backup drive': 673,
'You can unplug your transfer drive': 674,
'http://start.local was for setup only. It will no longer work.': 675, 'http://start.local was for setup only. It will no longer work.': 675,
'Download Address Info': 676, 'Download Address Info': 676,
"Contains your server's permanent local address and Root CA": 677, "Contains your server's permanent local address and Root CA": 677,
'USB Removed': 678, 'Remove Installation Media': 678,
'Remove the USB installation media from your server': 679, 'Remove USB stick or other installation media from your server': 679,
'Restart Server': 680, 'Restart Server': 680,
'Waiting for server to come back online': 681, 'Waiting for server to come back online': 681,
'Server is back online': 682, 'Server is back online': 682,

View File

@@ -5,7 +5,7 @@ export default {
2: 'Actualizar', 2: 'Actualizar',
4: 'Sistema', 4: 'Sistema',
5: 'General', 5: 'General',
6: 'Correo electrónico', 6: 'SMTP',
7: 'Crear copia de seguridad', 7: 'Crear copia de seguridad',
8: 'Restaurar copia de seguridad', 8: 'Restaurar copia de seguridad',
9: 'Ir a inicio de sesión', 9: 'Ir a inicio de sesión',
@@ -100,6 +100,7 @@ export default {
102: 'Salir', 102: 'Salir',
103: '¿Estás seguro?', 103: '¿Estás seguro?',
104: 'Nueva puerta de enlace de red', 104: 'Nueva puerta de enlace de red',
107: 'dominios onion',
108: 'público', 108: 'público',
109: 'privado', 109: 'privado',
111: 'Sin dominios onion', 111: 'Sin dominios onion',
@@ -384,8 +385,8 @@ export default {
405: 'Conectado', 405: 'Conectado',
406: 'Olvidar', 406: 'Olvidar',
407: 'Credenciales WiFi', 407: 'Credenciales WiFi',
408: 'Obsoleto', 408: 'Conectar a red oculta',
409: 'El soporte para WiFi será eliminado en StartOS v0.4.1. Si no tienes acceso a Ethernet, puedes usar un extensor WiFi para conectarte a la red local y luego conectar tu servidor al extensor por Ethernet. Por favor, contacta al soporte de Start9 si tienes dudas o inquietudes.', 409: 'Conectar a',
410: 'Redes conocidas', 410: 'Redes conocidas',
411: 'Otras redes', 411: 'Otras redes',
412: 'WiFi está deshabilitado', 412: 'WiFi está deshabilitado',
@@ -639,13 +640,11 @@ export default {
667: 'Iniciando configuración', 667: 'Iniciando configuración',
670: 'Espere 12 minutos y actualice la página', 670: 'Espere 12 minutos y actualice la página',
672: '¡Configuración completa!', 672: '¡Configuración completa!',
673: 'Puede desconectar su unidad de copia de seguridad',
674: 'Puede desconectar su unidad de transferencia',
675: 'http://start.local era solo para la configuración. Ya no funcionará.', 675: 'http://start.local era solo para la configuración. Ya no funcionará.',
676: 'Descargar información de direcciones', 676: 'Descargar información de direcciones',
677: 'Contiene la dirección local permanente de su servidor y la CA raíz', 677: 'Contiene la dirección local permanente de su servidor y la CA raíz',
678: 'USB retirado', 678: 'Retirar medio de instalación',
679: 'Retire el medio de instalación USB de su servidor', 679: 'Retire la memoria USB u otro medio de instalación de su servidor',
680: 'Reiniciar servidor', 680: 'Reiniciar servidor',
681: 'Esperando a que el servidor vuelva a estar en línea', 681: 'Esperando a que el servidor vuelva a estar en línea',
682: 'El servidor ha vuelto a estar en línea', 682: 'El servidor ha vuelto a estar en línea',

View File

@@ -5,7 +5,7 @@ export default {
2: 'Mettre à jour', 2: 'Mettre à jour',
4: 'Système', 4: 'Système',
5: 'Général', 5: 'Général',
6: 'Email', 6: 'SMTP',
7: 'Créer une sauvegarde', 7: 'Créer une sauvegarde',
8: 'Restaurer une sauvegarde', 8: 'Restaurer une sauvegarde',
9: 'Se connecter', 9: 'Se connecter',
@@ -100,6 +100,7 @@ export default {
102: 'Quitter', 102: 'Quitter',
103: 'Êtes-vous sûr ?', 103: 'Êtes-vous sûr ?',
104: 'Nouvelle passerelle réseau', 104: 'Nouvelle passerelle réseau',
107: 'domaine onion',
108: 'public', 108: 'public',
109: 'privé', 109: 'privé',
111: 'Aucune domaine onion', 111: 'Aucune domaine onion',
@@ -384,8 +385,8 @@ export default {
405: 'Connecté', 405: 'Connecté',
406: 'Oublier', 406: 'Oublier',
407: 'Identifiants WiFi', 407: 'Identifiants WiFi',
408: 'Obsolète', 408: 'Se connecter à un réseau masqué',
409: 'Le support WiFi sera supprimé dans StartOS v0.4.1. Si vous navez pas accès à internet via Ethernet, vous pouvez utiliser un répéteur WiFi pour vous connecter au réseau local, puis brancher votre serveur sur le répéteur en Ethernet. Contactez le support Start9 pour toute question.', 409: 'Se connecter à',
410: 'Réseaux connus', 410: 'Réseaux connus',
411: 'Autres réseaux', 411: 'Autres réseaux',
412: 'Le WiFi est désactivé', 412: 'Le WiFi est désactivé',
@@ -639,13 +640,11 @@ export default {
667: 'Démarrage de la configuration', 667: 'Démarrage de la configuration',
670: 'Attendez 1 à 2 minutes puis actualisez la page', 670: 'Attendez 1 à 2 minutes puis actualisez la page',
672: 'Configuration terminée !', 672: 'Configuration terminée !',
673: 'Vous pouvez débrancher votre disque de sauvegarde',
674: 'Vous pouvez débrancher votre disque de transfert',
675: 'http://start.local était réservé à la configuration. Il ne fonctionnera plus.', 675: 'http://start.local était réservé à la configuration. Il ne fonctionnera plus.',
676: 'Télécharger les informations dadresse', 676: 'Télécharger les informations dadresse',
677: 'Contient ladresse locale permanente de votre serveur et la CA racine', 677: 'Contient l\u2019adresse locale permanente de votre serveur et la CA racine',
678: 'USB retiré', 678: 'Retirer le support d\u2019installation',
679: 'Retirez le support dinstallation USB de votre serveur', 679: 'Retirez la clé USB ou tout autre support d\u2019installation de votre serveur',
680: 'Redémarrer le serveur', 680: 'Redémarrer le serveur',
681: 'En attente du retour en ligne du serveur', 681: 'En attente du retour en ligne du serveur',
682: 'Le serveur est de nouveau en ligne', 682: 'Le serveur est de nouveau en ligne',

View File

@@ -5,7 +5,7 @@ export default {
2: 'Aktualizuj', 2: 'Aktualizuj',
4: 'Ustawienia', 4: 'Ustawienia',
5: 'Ogólne', 5: 'Ogólne',
6: 'E-mail', 6: 'SMTP',
7: 'Utwórz kopię zapasową', 7: 'Utwórz kopię zapasową',
8: 'Przywróć z kopii zapasowej', 8: 'Przywróć z kopii zapasowej',
9: 'Przejdź do logowania', 9: 'Przejdź do logowania',
@@ -100,6 +100,7 @@ export default {
102: 'Opuść', 102: 'Opuść',
103: 'Czy jesteś pewien?', 103: 'Czy jesteś pewien?',
104: 'Nowa brama sieciowa', 104: 'Nowa brama sieciowa',
107: 'domeny onion',
108: 'publiczny', 108: 'publiczny',
109: 'prywatny', 109: 'prywatny',
111: 'Brak domeny onion', 111: 'Brak domeny onion',
@@ -384,8 +385,8 @@ export default {
405: 'Połączono', 405: 'Połączono',
406: 'Zapomnij', 406: 'Zapomnij',
407: 'Dane logowania WiFi', 407: 'Dane logowania WiFi',
408: 'Przestarzałe', 408: 'Połącz z ukrytą siecią',
409: 'Obsługa WiFi zostanie usunięta w StartOS v0.4.1. Jeśli nie masz dostępu do sieci Ethernet, możesz użyć wzmacniacza WiFi do połączenia z siecią lokalną, a następnie podłączyć serwer do wzmacniacza przez Ethernet. W razie pytań lub wątpliwości skontaktuj się z pomocą techniczną Start9.', 409: 'Połącz z',
410: 'Znane sieci', 410: 'Znane sieci',
411: 'Inne sieci', 411: 'Inne sieci',
412: 'WiFi jest wyłączone', 412: 'WiFi jest wyłączone',
@@ -639,13 +640,11 @@ export default {
667: 'Rozpoczynanie konfiguracji', 667: 'Rozpoczynanie konfiguracji',
670: 'Poczekaj 12 minuty i odśwież stronę', 670: 'Poczekaj 12 minuty i odśwież stronę',
672: 'Konfiguracja zakończona!', 672: 'Konfiguracja zakończona!',
673: 'Możesz odłączyć dysk kopii zapasowej',
674: 'Możesz odłączyć dysk transferowy',
675: 'http://start.local służył tylko do konfiguracji. Nie będzie już działać.', 675: 'http://start.local służył tylko do konfiguracji. Nie będzie już działać.',
676: 'Pobierz informacje adresowe', 676: 'Pobierz informacje adresowe',
677: 'Zawiera stały lokalny adres serwera oraz główny urząd certyfikacji (Root CA)', 677: 'Zawiera stały lokalny adres serwera oraz główny urząd certyfikacji (Root CA)',
678: 'USB usunięty', 678: 'Usuń nośnik instalacyjny',
679: 'Usuń instalacyjny nośnik USB z serwera', 679: 'Usuń pamięć USB lub inny nośnik instalacyjny z serwera',
680: 'Uruchom ponownie serwer', 680: 'Uruchom ponownie serwer',
681: 'Oczekiwanie na ponowne połączenie serwera', 681: 'Oczekiwanie na ponowne połączenie serwera',
682: 'Serwer jest ponownie online', 682: 'Serwer jest ponownie online',

View File

@@ -1,5 +1,6 @@
import { inject, Injectable, Pipe, PipeTransform } from '@angular/core' import { inject, Injectable, Pipe, PipeTransform } from '@angular/core'
import { i18nService } from './i18n.service' import { i18nService } from './i18n.service'
import { I18N } from './i18n.providers'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
@Pipe({ @Pipe({
@@ -9,8 +10,10 @@ import { T } from '@start9labs/start-sdk'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class LocalizePipe implements PipeTransform { export class LocalizePipe implements PipeTransform {
private readonly i18nService = inject(i18nService) private readonly i18nService = inject(i18nService)
private readonly i18n = inject(I18N)
transform(string: T.LocaleString): string { transform(string: T.LocaleString): string {
this.i18n() // read signal to trigger change detection on language switch
return this.i18nService.localize(string) return this.i18nService.localize(string)
} }
} }

View File

@@ -35,7 +35,7 @@ type OnionForm = {
selector: 'section[torDomains]', selector: 'section[torDomains]',
template: ` template: `
<header> <header>
Tor Domains {{ 'Tor Domains' | i18n }}
<a <a
tuiIconButton tuiIconButton
docsLink docsLink

View File

@@ -28,7 +28,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left"> <a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }} {{ 'Back' | i18n }}
</a> </a>
{{ 'Email' | i18n }} {{ 'SMTP' | i18n }}
</ng-container> </ng-container>
@if (form$ | async; as form) { @if (form$ | async; as form) {
<form [formGroup]="form"> <form [formGroup]="form">

View File

@@ -160,7 +160,11 @@ export default class SystemSSHComponent {
const loader = this.loader.open('Deleting').subscribe() const loader = this.loader.open('Deleting').subscribe()
try { try {
await this.api.deleteSshKey({ fingerprint: '' }) await Promise.all(
fingerprints.map(fingerprint =>
this.api.deleteSshKey({ fingerprint }),
),
)
this.local$.next( this.local$.next(
all.filter(s => !fingerprints.includes(s.fingerprint)), all.filter(s => !fingerprints.includes(s.fingerprint)),
) )

View File

@@ -5,8 +5,23 @@ import {
inject, inject,
Input, Input,
} from '@angular/core' } from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared' import { NgTemplateOutlet } from '@angular/common'
import { TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core' import {
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { filter } from 'rxjs'
import { IST } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiIcon,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import { TuiBadge, TuiFade } from '@taiga-ui/kit' import { TuiBadge, TuiFade } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout' import { TuiCell } from '@taiga-ui/layout'
import { import {
@@ -22,50 +37,78 @@ import { wifiSpec } from './wifi.const'
@Component({ @Component({
selector: '[wifi]', selector: '[wifi]',
template: ` template: `
@for (network of wifi; track $index) { <ng-template #row let-network>
@if (network.ssid) { @if (getSignal(network.strength); as signal) {
<tui-icon
background="@tui.wifi"
[icon]="signal.icon"
[style.background]="'var(--tui-background-neutral-2)'"
[style.color]="signal.color"
/>
} @else {
<tui-icon icon="@tui.wifi-off" />
}
<tui-icon
[icon]="network.security.length ? '@tui.lock' : '@tui.lock-open'"
/>
<div tuiTitle>
<strong tuiFade>
{{ network.ssid }}
</strong>
</div>
@if (network.connected) {
<tui-badge appearance="positive">
{{ 'Connected' | i18n }}
</tui-badge>
}
@if (network.connected === false) {
<button <button
tuiCell tuiIconButton
[disabled]="network.connected" tuiDropdown
(click)="prompt(network)" size="s"
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[(tuiDropdownOpen)]="open"
> >
<div tuiTitle> {{ 'More' | i18n }}
<strong tuiFade> <tui-data-list *tuiTextfieldDropdown>
{{ network.ssid }}
@if (network.connected) {
<tui-badge appearance="positive">
{{ 'Connected' | i18n }}
</tui-badge>
}
</strong>
</div>
@if (network.connected !== undefined) {
<button <button
tuiIconButton tuiOption
size="s" new
appearance="icon" iconStart="@tui.wifi"
iconStart="@tui.trash-2" (click)="prompt(network)"
(click.stop)="forget(network)" >
{{ 'Connect' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="forget(network)"
> >
{{ 'Forget' | i18n }} {{ 'Forget' | i18n }}
</button> </button>
} @else { </tui-data-list>
<tui-icon
[icon]="network.security.length ? '@tui.lock' : '@tui.lock-open'"
/>
}
@if (getSignal(network.strength); as signal) {
<tui-icon
background="@tui.wifi"
[icon]="signal.icon"
[style.background]="'var(--tui-background-neutral-2)'"
[style.color]="signal.color"
/>
} @else {
<tui-icon icon="@tui.wifi-off" />
}
</button> </button>
} }
</ng-template>
@for (network of wifi; track $index) {
@if (network.ssid) {
@if (network.connected === undefined) {
<button tuiCell (click)="prompt(network)">
<ng-container
*ngTemplateOutlet="row; context: { $implicit: network }"
/>
</button>
} @else {
<div tuiCell>
<ng-container
*ngTemplateOutlet="row; context: { $implicit: network }"
/>
</div>
}
}
} }
`, `,
styles: ` styles: `
@@ -75,8 +118,6 @@ import { wifiSpec } from './wifi.const'
} }
[tuiCell] { [tuiCell] {
padding-inline: 1rem !important;
&:disabled > * { &:disabled > * {
opacity: 1; opacity: 1;
} }
@@ -88,11 +129,24 @@ import { wifiSpec } from './wifi.const'
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiCell, TuiTitle, TuiBadge, TuiButton, TuiIcon, TuiFade, i18nPipe], imports: [
NgTemplateOutlet,
TuiCell,
TuiTitle,
TuiBadge,
TuiButton,
TuiIcon,
TuiFade,
TuiDropdown,
TuiDataList,
TuiTextfield,
i18nPipe,
],
}) })
export class WifiTableComponent { export class WifiTableComponent {
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly dialogs = inject(DialogService)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService) private readonly formDialog = inject(FormDialogService)
private readonly component = inject(SystemWifiComponent) private readonly component = inject(SystemWifiComponent)
@@ -102,6 +156,8 @@ export class WifiTableComponent {
@Input() @Input()
wifi: readonly Wifi[] = [] wifi: readonly Wifi[] = []
open = false
getSignal(signal: number) { getSignal(signal: number) {
if (signal < 5) { if (signal < 5) {
return null return null
@@ -141,17 +197,30 @@ export class WifiTableComponent {
async prompt(network: Wifi): Promise<void> { async prompt(network: Wifi): Promise<void> {
if (!network.security.length) { if (!network.security.length) {
await this.component.saveAndConnect(network.ssid) this.dialogs
.openConfirm({
label: `${this.i18n.transform('Connect to')} ${network.ssid}?`,
size: 's',
})
.pipe(filter(Boolean))
.subscribe(() => this.component.saveAndConnect(network.ssid))
} else { } else {
const ssid = wifiSpec.spec['ssid'] as IST.ValueSpecText
const spec: IST.InputSpec = {
...wifiSpec.spec,
ssid: { ...ssid, disabled: 'ssid', default: network.ssid },
}
this.formDialog.open<FormContext<WiFiForm>>(FormComponent, { this.formDialog.open<FormContext<WiFiForm>>(FormComponent, {
label: 'Password needed', label: 'Password needed',
data: { data: {
spec: wifiSpec.spec, spec,
value: { ssid: network.ssid, password: '' },
buttons: [ buttons: [
{ {
text: this.i18n.transform('Connect')!, text: this.i18n.transform('Connect')!,
handler: async ({ ssid, password }) => handler: async ({ password }) =>
this.component.saveAndConnect(ssid, password), this.component.saveAndConnect(network.ssid, password),
}, },
], ],
}, },

View File

@@ -8,6 +8,7 @@ import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
import { import {
DocsLinkDirective,
ErrorService, ErrorService,
i18nKey, i18nKey,
i18nPipe, i18nPipe,
@@ -19,11 +20,9 @@ import {
TuiAppearance, TuiAppearance,
TuiButton, TuiButton,
TuiLoader, TuiLoader,
TuiNotification,
TuiTitle,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { TuiSwitch } from '@taiga-ui/kit' import { TuiSwitch } from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { TuiCardLarge } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { catchError, defer, map, merge, Observable, of, Subject } from 'rxjs' import { catchError, defer, map, merge, Observable, of, Subject } from 'rxjs'
import { import {
@@ -47,23 +46,20 @@ import { wifiSpec } from './wifi.const'
</a> </a>
WiFi WiFi
</ng-container> </ng-container>
<header tuiHeader>
<tui-notification appearance="negative">
<div tuiTitle>
{{ 'Deprecated' | i18n }}
<div tuiSubtitle>
{{
'WiFi support will be removed in StartOS v0.4.1. If you do not have access to Ethernet, you can use a WiFi extender to connect to the local network, then connect your server to the extender via Ethernet. Please contact Start9 support with any questions or concerns.'
| i18n
}}
</div>
</div>
</tui-notification>
</header>
@if (status()?.interface) { @if (status()?.interface) {
<section class="g-card"> <section class="g-card">
<header> <header>
Wi-Fi Wi-Fi
<a
tuiIconButton
size="xs"
docsLink
path="/user-manual/wifi.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
<input <input
type="checkbox" type="checkbox"
tuiSwitch tuiSwitch
@@ -92,8 +88,8 @@ import { wifiSpec } from './wifi.const'
></div> ></div>
} }
<p> <p>
<button tuiButton (click)="other(data)"> <button tuiButton (click)="other(data)" appearance="flat">
{{ 'Add' | i18n }} + {{ 'Connect to hidden network' | i18n }}
</button> </button>
</p> </p>
} @else { } @else {
@@ -128,10 +124,8 @@ import { wifiSpec } from './wifi.const'
TitleDirective, TitleDirective,
RouterLink, RouterLink,
PlaceholderComponent, PlaceholderComponent,
TuiHeader,
TuiTitle,
TuiNotification,
i18nPipe, i18nPipe,
DocsLinkDirective,
], ],
}) })
export default class SystemWifiComponent { export default class SystemWifiComponent {

View File

@@ -1,4 +1,3 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
@@ -6,12 +5,9 @@ import { i18nPipe } from '@start9labs/shared'
import { TuiIcon, TuiTitle } from '@taiga-ui/core' import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiBadgeNotification } from '@taiga-ui/kit' import { TuiBadgeNotification } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout' import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { BadgeService } from 'src/app/services/badge.service' import { BadgeService } from 'src/app/services/badge.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service' import { TitleDirective } from 'src/app/services/title.service'
import { SYSTEM_MENU } from './system.const' import { SYSTEM_MENU } from './system.const'
import { map } from 'rxjs'
@Component({ @Component({
template: ` template: `
@@ -26,9 +22,6 @@ import { map } from 'rxjs'
tuiCell="s" tuiCell="s"
routerLinkActive="active" routerLinkActive="active"
[routerLink]="page.link" [routerLink]="page.link"
[style.display]="
!(wifiEnabled$ | async) && page.item === 'WiFi' ? 'none' : null
"
> >
<tui-icon [icon]="page.icon" /> <tui-icon [icon]="page.icon" />
<span tuiTitle> <span tuiTitle>
@@ -116,13 +109,9 @@ import { map } from 'rxjs'
TitleDirective, TitleDirective,
TuiBadgeNotification, TuiBadgeNotification,
i18nPipe, i18nPipe,
AsyncPipe,
], ],
}) })
export class SystemComponent { export class SystemComponent {
readonly menu = SYSTEM_MENU readonly menu = SYSTEM_MENU
readonly badge = toSignal(inject(BadgeService).getCount('system')) readonly badge = toSignal(inject(BadgeService).getCount('system'))
readonly wifiEnabled$ = inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'wifi')
.pipe(map(wifi => !!wifi.interface && wifi.enabled))
} }

View File

@@ -28,7 +28,7 @@ export const SYSTEM_MENU = [
}, },
{ {
icon: '@tui.mail', icon: '@tui.mail',
item: 'Email', item: 'SMTP',
link: 'email', link: 'email',
}, },
{ {

View File

@@ -4,6 +4,7 @@ import {
ErrorService, ErrorService,
i18nKey, i18nKey,
i18nPipe, i18nPipe,
i18nService,
LoadingService, LoadingService,
} from '@start9labs/shared' } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
@@ -24,6 +25,7 @@ export class ControlsService {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB) private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly i18n = inject(i18nPipe) private readonly i18n = inject(i18nPipe)
private readonly i18nService = inject(i18nService)
async start({ title, alerts, id }: T.Manifest, unmet: boolean) { async start({ title, alerts, id }: T.Manifest, unmet: boolean) {
const deps = const deps =
@@ -31,7 +33,7 @@ export class ControlsService {
if ( if (
(unmet && !(await this.alert(deps))) || (unmet && !(await this.alert(deps))) ||
(alerts.start && !(await this.alert(alerts.start as i18nKey))) (alerts.start && !(await this.alert(alerts.start)))
) { ) {
return return
} }
@@ -49,7 +51,7 @@ export class ControlsService {
async stop({ id, title, alerts }: T.Manifest) { async stop({ id, title, alerts }: T.Manifest) {
const depMessage = `${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}` const depMessage = `${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
let content = alerts.stop || '' let content = alerts.stop ? this.i18nService.localize(alerts.stop) : ''
if (hasCurrentDeps(id, await getAllPackages(this.patch))) { if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
content = content ? `${content}.\n\n${depMessage}` : depMessage content = content ? `${content}.\n\n${depMessage}` : depMessage
@@ -113,14 +115,14 @@ export class ControlsService {
}) })
} }
private alert(content: i18nKey): Promise<boolean> { private alert(content: T.LocaleString): Promise<boolean> {
return firstValueFrom( return firstValueFrom(
this.dialog this.dialog
.openConfirm({ .openConfirm({
label: 'Warning', label: 'Warning',
size: 's', size: 's',
data: { data: {
content, content: this.i18nService.localize(content),
yes: 'Continue', yes: 'Continue',
no: 'Cancel', no: 'Cancel',
}, },

View File

@@ -31,7 +31,7 @@ export function getInstalledBaseStatus(statusInfo: T.StatusInfo): BaseStatus {
(!statusInfo.started || (!statusInfo.started ||
Object.values(statusInfo.health) Object.values(statusInfo.health)
.filter(h => !!h) .filter(h => !!h)
.some(h => h.result === 'starting')) .some(h => h.result === 'starting' || h.result === 'waiting'))
) { ) {
return 'starting' return 'starting'
} }

View File

@@ -5,6 +5,7 @@ import {
ErrorService, ErrorService,
i18nKey, i18nKey,
i18nPipe, i18nPipe,
i18nService,
LoadingService, LoadingService,
} from '@start9labs/shared' } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
@@ -27,6 +28,7 @@ export class StandardActionsService {
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly router = inject(Router) private readonly router = inject(Router)
private readonly i18n = inject(i18nPipe) private readonly i18n = inject(i18nPipe)
private readonly i18nService = inject(i18nService)
async rebuild(id: string) { async rebuild(id: string) {
const loader = this.loader.open('Rebuilding container').subscribe() const loader = this.loader.open('Rebuilding container').subscribe()
@@ -50,11 +52,12 @@ export class StandardActionsService {
): Promise<void> { ): Promise<void> {
let content = soft let content = soft
? '' ? ''
: alerts.uninstall || : alerts.uninstall
`${this.i18n.transform('Uninstalling')} ${title} ${this.i18n.transform('will permanently delete its data.')}` ? this.i18nService.localize(alerts.uninstall)
: `${this.i18n.transform('Uninstalling')} ${title} ${this.i18n.transform('will permanently delete its data.')}`
if (hasCurrentDeps(id, await getAllPackages(this.patch))) { if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
content = `${content}${content ? ' ' : ''}${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}` content = `${content ? `${content} ` : ''}${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
} }
if (!content) { if (!content) {