feat: unified restart notification with reason-specific messaging (#3147)

* 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>

* fix broken styling and improve settings layout

* refactor: move restart field from ServerInfo to ServerStatus

The restart reason belongs with other server state (shutting_down,
restarting, update_progress) rather than on the top-level ServerInfo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix PR comment

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Matt Hill
2026-03-29 02:23:59 -06:00
committed by GitHub
parent bbbc8f7440
commit b0b4b41c42
22 changed files with 192 additions and 203 deletions

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,9 @@ export class PortalComponent {
readonly name = toSignal(this.patch.watch$('serverInfo', 'name'))
readonly update = toSignal(inject(OSService).updating$)
readonly restartReason = toSignal(
this.patch.watch$('serverInfo', 'statusInfo', '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,9 +24,9 @@ export namespace Mock {
export const ServerUpdated: T.ServerStatus = {
backupProgress: null,
updateProgress: null,
updated: true,
restarting: false,
shuttingDown: false,
restart: null,
}
export const RegistryOSUpdate: T.OsVersionInfoMap = {

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/statusInfo/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/statusInfo/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/statusInfo/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/statusInfo/restart',
value: 'update',
},
{
op: PatchOp.REMOVE,

View File

@@ -227,11 +227,11 @@ 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',

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(),
)