feat: unified restart notification with reason-specific messaging

Replace statusInfo.updated (bool) with serverInfo.restart (nullable enum)
to unify all restart-needed scenarios under a single PatchDB field.

Backend sets the restart reason in RPC handlers for hostname change (mdns),
language change, kiosk toggle, and OS update download. Init clears it on
boot. The update flow checks this field to prevent updates when a restart
is already pending.

Frontend shows a persistent action bar with reason-specific i18n messages
instead of per-feature restart dialogs. For .local hostname changes, the
existing "open new address" dialog is preserved — the restart toast
appears after the user logs in on the new address.

Also includes migration in v0_4_0_alpha_23 to remove statusInfo.updated
and initialize serverInfo.restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Hill
2026-03-28 22:35:34 -06:00
parent d6b81f3c9b
commit 591e3bec1a
21 changed files with 160 additions and 166 deletions

View File

@@ -485,7 +485,6 @@ export default {
512: 'Der Kiosk-Modus ist auf diesem Gerät nicht verfügbar',
513: 'Aktivieren',
514: 'Deaktivieren',
515: 'Diese Änderung wird nach dem nächsten Neustart wirksam',
516: 'Empfohlen',
517: 'Möchten Sie diese Aufgabe wirklich verwerfen?',
518: 'Verwerfen',
@@ -717,11 +716,12 @@ export default {
799: 'Nach Klick auf "Enroll MOK":',
800: 'Geben Sie bei Aufforderung Ihr StartOS-Passwort ein',
801: 'Ihr System hat Secure Boot aktiviert, was erfordert, dass alle Kernel-Module mit einem vertrauenswürdigen Schlüssel signiert sind. Einige Hardware-Treiber \u2014 wie die für NVIDIA-GPUs \u2014 sind nicht mit dem Standard-Distributionsschlüssel signiert. Die Registrierung des StartOS-Signaturschlüssels ermöglicht es Ihrer Firmware, diesen Modulen zu vertrauen, damit Ihre Hardware vollständig genutzt werden kann.',
802: 'Die Übersetzungen auf Betriebssystemebene sind bereits aktiv. Ein Neustart ist erforderlich, damit die Übersetzungen auf Dienstebene wirksam werden.',
803: 'Dieses Laufwerk verwendet ext4 und wird automatisch in btrfs konvertiert. Ein Backup wird dringend empfohlen, bevor Sie fortfahren.',
804: 'Ich habe ein Backup meiner Daten',
805: 'Öffentliche Domain hinzufügen',
806: 'Ergebnis',
807: 'Nach dem Öffnen der neuen Adresse werden Sie zum Neustart aufgefordert.',
808: 'Ein Neustart ist erforderlich, damit die Dienstschnittstellen den neuen Hostnamen verwenden.',
807: 'Download abgeschlossen. Neustart zum Anwenden.',
808: 'Hostname geändert, Neustart damit installierte Dienste die neue Adresse verwenden',
809: 'Sprache geändert, Neustart damit installierte Dienste die neue Sprache verwenden',
810: 'Kioskmodus geändert, Neustart zum Anwenden',
} satisfies i18n

View File

@@ -484,7 +484,6 @@ export const ENGLISH: Record<string, number> = {
'Kiosk Mode is unavailable on this device': 512,
'Enable': 513,
'Disable': 514,
'This change will take effect after the next boot': 515,
'Recommended': 516, // as in, we recommend this
'Are you sure you want to dismiss this task?': 517,
'Dismiss': 518, // as in, dismiss or delete a task
@@ -718,11 +717,12 @@ export const ENGLISH: Record<string, number> = {
'After clicking "Enroll MOK":': 799,
'When prompted, enter your StartOS password': 800,
'Your system has Secure Boot enabled, which requires all kernel modules to be signed with a trusted key. Some hardware drivers \u2014 such as those for NVIDIA GPUs \u2014 are not signed by the default distribution key. Enrolling the StartOS signing key allows your firmware to trust these modules so your hardware can be fully utilized.': 801,
'OS-level translations are already in effect. A restart is required for service-level translations to take effect.': 802,
'This drive uses ext4 and will be automatically converted to btrfs. A backup is strongly recommended before proceeding.': 803,
'I have a backup of my data': 804,
'Add Public Domain': 805,
'Result': 806,
'After opening the new address, you will be prompted to restart.': 807,
'A restart is required for service interfaces to use the new hostname.': 808,
'Download complete. Restart to apply.': 807,
'Hostname changed, restart for installed services to use the new address': 808,
'Language changed, restart for installed services to use the new language': 809,
'Kiosk mode changed, restart to apply': 810,
}

View File

@@ -485,7 +485,6 @@ export default {
512: 'El modo quiosco no está disponible en este dispositivo',
513: 'Activar',
514: 'Desactivar',
515: 'Este cambio tendrá efecto después del próximo inicio',
516: 'Recomendado',
517: '¿Estás seguro de que deseas descartar esta tarea?',
518: 'Descartar',
@@ -717,11 +716,12 @@ export default {
799: 'Después de hacer clic en "Enroll MOK":',
800: 'Cuando se le solicite, ingrese su contraseña de StartOS',
801: 'Su sistema tiene Secure Boot habilitado, lo que requiere que todos los módulos del kernel estén firmados con una clave de confianza. Algunos controladores de hardware \u2014 como los de las GPU NVIDIA \u2014 no están firmados con la clave de distribución predeterminada. Registrar la clave de firma de StartOS permite que su firmware confíe en estos módulos para que su hardware pueda utilizarse completamente.',
802: 'Las traducciones a nivel del sistema operativo ya están en vigor. Se requiere un reinicio para que las traducciones a nivel de servicio surtan efecto.',
803: 'Esta unidad usa ext4 y se convertirá automáticamente a btrfs. Se recomienda encarecidamente hacer una copia de seguridad antes de continuar.',
804: 'Tengo una copia de seguridad de mis datos',
805: 'Agregar dominio público',
806: 'Resultado',
807: 'Después de abrir la nueva dirección, se le pedirá que reinicie.',
808: 'Se requiere un reinicio para que las interfaces de servicio utilicen el nuevo nombre de host.',
807: 'Descarga completa. Reiniciar para aplicar.',
808: 'Nombre de host cambiado, reiniciar para que los servicios instalados usen la nueva dirección',
809: 'Idioma cambiado, reiniciar para que los servicios instalados usen el nuevo idioma',
810: 'Modo kiosco cambiado, reiniciar para aplicar',
} satisfies i18n

View File

@@ -485,7 +485,6 @@ export default {
512: 'Le mode kiosque nest pas disponible sur cet appareil',
513: 'Activer',
514: 'Désactiver',
515: 'Ce changement va prendre effet après le prochain démarrage',
516: 'Recommandé',
517: 'Êtes-vous sûr de vouloir ignorer cette tâche ?',
518: 'Ignorer',
@@ -717,11 +716,12 @@ export default {
799: 'Après avoir cliqué sur "Enroll MOK" :',
800: 'Lorsque vous y êtes invité, entrez votre mot de passe StartOS',
801: "Votre système a Secure Boot activé, ce qui exige que tous les modules du noyau soient signés avec une clé de confiance. Certains pilotes matériels \u2014 comme ceux des GPU NVIDIA \u2014 ne sont pas signés par la clé de distribution par défaut. L'enregistrement de la clé de signature StartOS permet à votre firmware de faire confiance à ces modules afin que votre matériel puisse être pleinement utilisé.",
802: "Les traductions au niveau du système d'exploitation sont déjà en vigueur. Un redémarrage est nécessaire pour que les traductions au niveau des services prennent effet.",
803: 'Ce disque utilise ext4 et sera automatiquement converti en btrfs. Il est fortement recommandé de faire une sauvegarde avant de continuer.',
804: "J'ai une sauvegarde de mes données",
805: 'Ajouter un domaine public',
806: 'Résultat',
807: 'Après avoir ouvert la nouvelle adresse, vous serez invité à redémarrer.',
808: "Un redémarrage est nécessaire pour que les interfaces de service utilisent le nouveau nom d'hôte.",
807: 'Téléchargement terminé. Redémarrer pour appliquer.',
808: "Nom d'hôte modifié, redémarrer pour que les services installés utilisent la nouvelle adresse",
809: 'Langue modifiée, redémarrer pour que les services installés utilisent la nouvelle langue',
810: 'Mode kiosque modifié, redémarrer pour appliquer',
} satisfies i18n

View File

@@ -485,7 +485,6 @@ export default {
512: 'Tryb kiosku jest niedostępny na tym urządzeniu',
513: 'Włącz',
514: 'Wyłącz',
515: 'Ta zmiana zacznie obowiązywać po następnym uruchomieniu',
516: 'Zalecane',
517: 'Czy na pewno chcesz odrzucić to zadanie?',
518: 'Odrzuć',
@@ -717,11 +716,12 @@ export default {
799: 'Po kliknięciu "Enroll MOK":',
800: 'Po wyświetleniu monitu wprowadź swoje hasło StartOS',
801: 'Twój system ma włączony Secure Boot, co wymaga, aby wszystkie moduły jądra były podpisane zaufanym kluczem. Niektóre sterowniki sprzętowe \u2014 takie jak te dla GPU NVIDIA \u2014 nie są podpisane domyślnym kluczem dystrybucji. Zarejestrowanie klucza podpisu StartOS pozwala firmware ufać tym modułom, aby sprzęt mógł być w pełni wykorzystany.',
802: 'Tłumaczenia na poziomie systemu operacyjnego są już aktywne. Wymagane jest ponowne uruchomienie, aby tłumaczenia na poziomie usług zaczęły obowiązywać.',
803: 'Ten dysk używa ext4 i zostanie automatycznie skonwertowany na btrfs. Zdecydowanie zaleca się wykonanie kopii zapasowej przed kontynuowaniem.',
804: 'Mam kopię zapasową moich danych',
805: 'Dodaj domenę publiczną',
806: 'Wynik',
807: 'Po otwarciu nowego adresu zostaniesz poproszony o ponowne uruchomienie.',
808: 'Ponowne uruchomienie jest wymagane, aby interfejsy usług używały nowej nazwy hosta.',
807: 'Pobieranie zakończone. Uruchom ponownie, aby zastosować.',
808: 'Nazwa hosta zmieniona, uruchom ponownie, aby zainstalowane usługi używały nowego adresu',
809: 'Język zmieniony, uruchom ponownie, aby zainstalowane usługi używały nowego języka',
810: 'Tryb kiosku zmieniony, uruchom ponownie, aby zastosować',
} satisfies i18n

View File

@@ -6,7 +6,7 @@ import {
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterOutlet } from '@angular/router'
import { ErrorService } from '@start9labs/shared'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import {
TuiButton,
TuiCell,
@@ -39,10 +39,7 @@ import { HeaderComponent } from './components/header/header.component'
@if (update(); as update) {
<tui-action-bar *tuiPopup="bar()">
<span tuiCell="m">
@if (update === true) {
<tui-icon icon="@tui.check" class="g-positive" />
Download complete, restart to apply changes
} @else if (
@if (
update.overall && update.overall !== true && update.overall.total
) {
<tui-progress-circle
@@ -58,9 +55,36 @@ import { HeaderComponent } from './components/header/header.component'
Calculating download size
}
</span>
@if (update === true) {
<button tuiButton size="s" (click)="restart()">Restart</button>
}
</tui-action-bar>
}
@if (restartReason(); as reason) {
<tui-action-bar *tuiPopup="bar()">
<span tuiCell="m">
<tui-icon icon="@tui.refresh-cw" />
@switch (reason) {
@case ('update') {
{{ 'Download complete. Restart to apply.' | i18n }}
}
@case ('mdns') {
{{
'Hostname changed, restart for installed services to use the new address'
| i18n
}}
}
@case ('language') {
{{
'Language changed, restart for installed services to use the new language'
| i18n
}}
}
@case ('kiosk') {
{{ 'Kiosk mode changed, restart to apply' | i18n }}
}
}
</span>
<button tuiButton size="s" appearance="primary" (click)="restart()">
{{ 'Restart' | i18n }}
</button>
</tui-action-bar>
}
`,
@@ -114,6 +138,7 @@ import { HeaderComponent } from './components/header/header.component'
TuiButton,
TuiPopup,
TuiCell,
i18nPipe,
],
})
export class PortalComponent {
@@ -124,6 +149,7 @@ export class PortalComponent {
readonly name = toSignal(this.patch.watch$('serverInfo', 'name'))
readonly update = toSignal(inject(OSService).updating$)
readonly restartReason = toSignal(this.patch.watch$('serverInfo', 'restart'))
readonly bar = signal(true)
getProgress(size: number, downloaded: number): number {

View File

@@ -4,11 +4,10 @@ import {
Component,
inject,
INJECTOR,
OnInit,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
import { RouterLink } from '@angular/router'
import { WA_WINDOW } from '@ng-web-apis/common'
import {
DialogService,
@@ -48,6 +47,7 @@ import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import { ABOUT } from 'src/app/routes/portal/components/header/about.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { OSService } from 'src/app/services/os.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
@@ -96,14 +96,10 @@ import { UPDATE } from './update.component'
[disabled]="os.updatingOrBackingUp$ | async"
(click)="onUpdate()"
>
@if (server.statusInfo.updated) {
{{ 'Restart to apply' | i18n }}
@if (os.showUpdate$ | async) {
{{ 'Update' | i18n }}
} @else {
@if (os.showUpdate$ | async) {
{{ 'Update' | i18n }}
} @else {
{{ 'Check for updates' | i18n }}
}
{{ 'Check for updates' | i18n }}
}
</button>
</div>
@@ -278,7 +274,7 @@ import { UPDATE } from './update.component'
TuiAnimated,
],
})
export default class SystemGeneralComponent implements OnInit {
export default class SystemGeneralComponent {
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly loader = inject(TuiNotificationMiddleService)
private readonly errorService = inject(ErrorService)
@@ -288,20 +284,7 @@ export default class SystemGeneralComponent implements OnInit {
private readonly i18n = inject(i18nPipe)
private readonly injector = inject(INJECTOR)
private readonly win = inject(WA_WINDOW)
private readonly route = inject(ActivatedRoute)
private readonly router = inject(Router)
ngOnInit() {
this.route.queryParams
.pipe(filter(params => params['restart'] === 'hostname'))
.subscribe(async () => {
await this.router.navigate([], {
relativeTo: this.route,
queryParams: {},
})
this.promptHostnameRestart()
})
}
private readonly config = inject(ConfigService)
count = 0
@@ -321,7 +304,6 @@ export default class SystemGeneralComponent implements OnInit {
onLanguageChange(language: Language) {
this.i18nService.setLang(language.name)
this.promptLanguageRestart()
}
// Expose shared utilities for template use
@@ -371,9 +353,7 @@ export default class SystemGeneralComponent implements OnInit {
}
onUpdate() {
if (this.server()?.statusInfo.updated) {
this.restart()
} else if (this.os.updateAvailable$.value) {
if (this.os.updateAvailable$.value) {
this.update()
} else {
this.check()
@@ -400,7 +380,7 @@ export default class SystemGeneralComponent implements OnInit {
),
)
.subscribe(result => {
if (this.win.location.hostname.endsWith('.local')) {
if (this.config.accessType === 'mdns') {
this.confirmNameChange(result)
} else {
this.saveName(result)
@@ -433,24 +413,18 @@ export default class SystemGeneralComponent implements OnInit {
await this.api.setHostname({ name, hostname })
if (wasLocal) {
const { protocol, port } = this.win.location
const portSuffix = port ? ':' + port : ''
const newUrl = `${protocol}//${hostname}.local${portSuffix}/system/general?restart=hostname`
this.dialog
.openConfirm({
label: 'Hostname Changed',
data: {
content:
`${this.i18n.transform('Your server is now reachable at')} ${hostname}.local. ${this.i18n.transform('After opening the new address, you will be prompted to restart.')}` as i18nKey,
`${this.i18n.transform('Your server is now reachable at')} ${hostname}.local` as i18nKey,
yes: 'Open new address',
no: 'Dismiss',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.win.open(newUrl, '_blank'))
} else {
this.promptHostnameRestart()
.subscribe(() => this.win.open(`https://${hostname}.local`, '_blank'))
}
} catch (e: any) {
this.errorService.handleError(e)
@@ -526,7 +500,6 @@ export default class SystemGeneralComponent implements OnInit {
try {
await this.api.toggleKiosk(true)
this.promptRestart()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -546,7 +519,6 @@ export default class SystemGeneralComponent implements OnInit {
options: [],
})
await this.api.toggleKiosk(true)
this.promptRestart()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -559,7 +531,6 @@ export default class SystemGeneralComponent implements OnInit {
try {
await this.api.toggleKiosk(false)
this.promptRestart()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -567,50 +538,6 @@ export default class SystemGeneralComponent implements OnInit {
}
}
private promptRestart() {
this.dialog
.openConfirm({
label: 'Restart to apply',
data: {
content: 'This change will take effect after the next boot',
yes: 'Restart now',
no: 'Later',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.restart())
}
private promptHostnameRestart() {
this.dialog
.openConfirm({
label: 'Restart to apply',
data: {
content:
'A restart is required for service interfaces to use the new hostname.',
yes: 'Restart now',
no: 'Later',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.restart())
}
private promptLanguageRestart() {
this.dialog
.openConfirm({
label: 'Restart to apply',
data: {
content:
'OS-level translations are already in effect. A restart is required for service-level translations to take effect.',
yes: 'Restart now',
no: 'Later',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.restart())
}
private update() {
this.dialogs
.open(UPDATE, {

View File

@@ -24,7 +24,6 @@ export namespace Mock {
export const ServerUpdated: T.ServerStatus = {
backupProgress: null,
updateProgress: null,
updated: true,
restarting: false,
shuttingDown: false,
}

View File

@@ -435,14 +435,20 @@ export class MockApiService extends ApiService {
async toggleKiosk(enable: boolean): Promise<null> {
await pauseFor(2000)
const patch = [
this.mockRevision([
{
op: PatchOp.REPLACE,
path: '/serverInfo/kiosk',
value: enable,
},
]
this.mockRevision(patch)
])
this.mockRevision([
{
op: PatchOp.REPLACE,
path: '/serverInfo/restart',
value: 'kiosk',
},
])
return null
}
@@ -450,7 +456,7 @@ export class MockApiService extends ApiService {
async setHostname(params: T.SetServerHostnameParams): Promise<null> {
await pauseFor(1000)
const patch = [
this.mockRevision([
{
op: PatchOp.REPLACE,
path: '/serverInfo/name',
@@ -461,8 +467,14 @@ export class MockApiService extends ApiService {
path: '/serverInfo/hostname',
value: params.hostname,
},
]
this.mockRevision(patch)
])
this.mockRevision([
{
op: PatchOp.REPLACE,
path: '/serverInfo/restart',
value: 'mdns',
},
])
return null
}
@@ -485,14 +497,20 @@ export class MockApiService extends ApiService {
async setLanguage(params: SetLanguageParams): Promise<null> {
await pauseFor(1000)
const patch = [
this.mockRevision([
{
op: PatchOp.REPLACE,
path: '/serverInfo/language',
value: params.language,
},
]
this.mockRevision(patch)
])
this.mockRevision([
{
op: PatchOp.REPLACE,
path: '/serverInfo/restart',
value: 'language',
},
])
return null
}
@@ -1831,11 +1849,11 @@ export class MockApiService extends ApiService {
this.mockRevision(patch2)
setTimeout(async () => {
const patch3: Operation<boolean>[] = [
const patch3: Operation<string>[] = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/statusInfo/updated',
value: true,
path: '/serverInfo/restart',
value: 'update',
},
{
op: PatchOp.REMOVE,

View File

@@ -227,12 +227,12 @@ export const mockPatchData: DataModel = {
postInitMigrationTodos: {},
statusInfo: {
// currentBackup: null,
updated: false,
updateProgress: null,
restarting: false,
shuttingDown: false,
backupProgress: null,
},
restart: null,
name: 'Random Words',
hostname: 'random-words',
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',

View File

@@ -28,7 +28,7 @@ export class OSService {
.pipe(shareReplay({ bufferSize: 1, refCount: true }))
readonly updating$ = this.statusInfo$.pipe(
map(status => status.updateProgress ?? status.updated),
map(status => status.updateProgress ?? false),
distinctUntilChanged(),
)