mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 20:14:49 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -24,7 +24,6 @@ export namespace Mock {
|
||||
export const ServerUpdated: T.ServerStatus = {
|
||||
backupProgress: null,
|
||||
updateProgress: null,
|
||||
updated: true,
|
||||
restarting: false,
|
||||
shuttingDown: false,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user