Skip to content
13 changes: 13 additions & 0 deletions projects/start-cli/man/start-cli-server-cancel-deferred-power.1
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH start-cli-server-cancel-deferred-power 1 "cancel-deferred-power "
.SH NAME
start\-cli\-server\-cancel\-deferred\-power \- Cancel a restart or shutdown that is waiting for a backup to finish
.SH SYNOPSIS
\fBstart\-cli server cancel\-deferred\-power\fR [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Cancel a restart or shutdown that is waiting for a backup to finish
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
5 changes: 4 additions & 1 deletion projects/start-cli/man/start-cli-server-restart.1
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
.SH NAME
start\-cli\-server\-restart \- Restart the server
.SH SYNOPSIS
\fBstart\-cli server restart\fR [\fB\-\-nowait\fR] [\fB\-h\fR|\fB\-\-help\fR]
\fBstart\-cli server restart\fR [\fB\-\-nowait\fR] [\fB\-\-after\-backup\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Restart the server
.SH OPTIONS
.TP
\fB\-\-nowait\fR
Return immediately instead of waiting for graceful shutdown to complete
.TP
\fB\-\-after\-backup\fR
Wait for a running backup to finish first
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
5 changes: 4 additions & 1 deletion projects/start-cli/man/start-cli-server-shutdown.1
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
.SH NAME
start\-cli\-server\-shutdown \- Shutdown the server
.SH SYNOPSIS
\fBstart\-cli server shutdown\fR [\fB\-\-nowait\fR] [\fB\-h\fR|\fB\-\-help\fR]
\fBstart\-cli server shutdown\fR [\fB\-\-nowait\fR] [\fB\-\-after\-backup\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Shutdown the server
.SH OPTIONS
.TP
\fB\-\-nowait\fR
Return immediately instead of waiting for graceful shutdown to complete
.TP
\fB\-\-after\-backup\fR
Wait for a running backup to finish first
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
3 changes: 3 additions & 0 deletions projects/start-cli/man/start-cli-server.1
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ Commands related to the server i.e. restart, update, and shutdown
Print help
.SH SUBCOMMANDS
.TP
start\-cli\-server\-cancel\-deferred\-power(1)
Cancel a restart or shutdown that is waiting for a backup to finish
.TP
start\-cli\-server\-clear\-smtp(1)
Remove system smtp server and credentials
.TP
Expand Down
8 changes: 8 additions & 0 deletions projects/start-os/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ erasure-coded FUSE filesystem used for StartOS backups. It builds to the
`poweroff.target`/`halt.target`, not reboot); its `ExecStop` calls
`start-cli server shutdown`.
- `startos-restart.service` — restart handling.
- The physical power key is systemd-logind's (`HandlePowerKey=poweroff`),
except while a backup is running: `startd` then holds a logind
`handle-power-key` block inhibitor and reads the key itself, turning a press
into a shutdown that waits for the backup rather than one that interrupts it.
It is best-effort — when the inhibitor cannot be taken or no `power-switch`
device can be read, the key stays logind's — so treat it as one defence and
not a guarantee. See `start-core/src/power_key.rs` for why it inhibits
`handle-power-key` rather than `shutdown`.

## OS image packaging

Expand Down
14 changes: 14 additions & 0 deletions projects/start-os/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ file tracks notable changes since the move to the monorepo.
plaintext address, including the passwords typed into it. See
[Gateways](https://docs.start9.com/start-os/gateways.html).

- **Restarting or shutting down while a backup is running now asks first, and
can wait for the backup to finish.** Powering the server off part-way through
a backup can corrupt the backup of whichever service is being written at that
moment. Choosing `Restart` or `Shutdown` during a backup now offers to wait
for the backup instead, and waiting is what happens if you walk away — the
prompt counts down and takes that option for you. StartOS then carries out
the restart or shutdown as soon as the backup completes, and until then a bar
along the bottom of the screen says what is coming and lets you cancel it.
Pressing the server's physical power button during a backup waits for the
backup too, rather than powering off immediately. Over the CLI,
`start-cli server restart` and `server shutdown` take `--after-backup` for
the same behavior and `start-cli server cancel-deferred-power` calls it off.
See [Creating Backups](https://docs.start9.com/start-os/backup-create.html).

### Changed

- **The NVIDIA images now use NVIDIA's open kernel modules, which support GeForce
Expand Down
2 changes: 2 additions & 0 deletions projects/start-os/docs/src/backup-create.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Back up your server's data to a physical drive or a network folder.

1. To back up a service, StartOS first stops it (if it was running), performs the backup, then restarts it — but only if it was running beforehand. A service that was already stopped stays stopped. Consequently a service cannot be used while it is backing up, though you may continue to use your server and other services in the meantime.

1. Restarting or shutting down mid-backup can corrupt the backup of whichever service is being written at that moment, so StartOS asks first. Choosing `Restart` or `Shutdown` while a backup is running offers to wait for the backup to finish instead, and takes that option for you if you do not choose within 30 seconds — to power down regardless, choose the "now" option in that prompt. Pressing the server's physical power button during a backup waits for the backup rather than powering off, without asking. Either way StartOS performs the restart or shutdown as soon as the backup completes, and until then a bar along the bottom of the screen says what is coming and lets you cancel it.

1. Upon completion, StartOS issues a backup report, indicating which services were backed up, as well as any errors.

1. Backups are differential — each new backup to the same target overwrites the previous one. To maintain multiple backup points, use multiple backup targets.
Expand Down
14 changes: 12 additions & 2 deletions projects/start-os/docs/src/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,21 @@ Restart, shut down, update, and configure the server.

### `start-cli server restart`

Restart the server.
Restart the server. Without `--after-backup` this interrupts a running backup, unlike the web UI, which offers to wait.

- `--after-backup` — Wait for a running backup to finish first
- `--nowait` — Return immediately instead of waiting for graceful shutdown

### `start-cli server shutdown`

Shut down the server.
Shut down the server. Without `--after-backup` this interrupts a running backup, unlike the web UI, which offers to wait.

- `--after-backup` — Wait for a running backup to finish first
- `--nowait` — Return immediately instead of waiting for graceful shutdown

### `start-cli server cancel-deferred-power`

Cancel a restart or shutdown that is waiting for a backup to finish.

### `start-cli server update`

Expand Down
3 changes: 3 additions & 0 deletions projects/start-os/docs/src/surge-and-ups.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ There are three common topologies. For a home server, **line-interactive** is th
StartOS does not currently include built-in support for UPS monitoring (USB or network), so it cannot automatically shut down when the battery is low during an extended outage. The server will run until battery exhaustion and then power off uncleanly. This still carries some risk of data corruption, but it is dramatically less risky than facing the original surge, brownout, or sudden outage with no UPS at all.

If your area has frequent or long outages, size your UPS to give yourself time to shut down manually from the StartOS UI before the battery runs out.

> [!NOTE]
> If a backup is running when you do, StartOS offers to wait for the backup to finish and takes that option if you do not choose within 30 seconds — which on battery is rarely what you want. Choose `Shut down now` instead. The server's physical power button always waits for the backup, so on battery use the web UI rather than the button.
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {
DocsLinkDirective,
i18nPipe,
SafeLinksDirective,
TaskService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-core'
import {
TuiButton,
TuiDataList,
Expand All @@ -17,6 +17,7 @@ import {
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service'
import { PowerService } from 'src/app/services/power.service'
import { STATUS } from 'src/app/services/status.service'
import { ABOUT } from './about.component'

Expand Down Expand Up @@ -138,8 +139,8 @@ import { ABOUT } from './about.component'
export class HeaderMenuComponent {
private readonly api = inject(ApiService)
private readonly auth = inject(AuthService)
private readonly tasks = inject(TaskService)
private readonly dialog = inject(DialogService)
private readonly power = inject(PowerService)

open = false

Expand All @@ -149,7 +150,11 @@ export class HeaderMenuComponent {
this.dialog.openComponent(ABOUT, { label: 'About this server' }).subscribe()
}

async promptPower(action: 'restart' | 'shutdown') {
async promptPower(action: T.PowerAction) {
// During a backup the choice on offer is a different one, and asking it is
// confirmation enough.
if (this.power.backingUp()) return this.power.power(action).subscribe()

this.dialog
.openConfirm(
action === 'restart'
Expand All @@ -175,15 +180,7 @@ export class HeaderMenuComponent {
},
)
.pipe(filter(Boolean))
.subscribe(() =>
this.tasks.run(
async () =>
await this.api[
action === 'restart' ? 'restartServer' : 'shutdownServer'
]({}),
`Beginning ${action}`,
),
)
.subscribe(() => this.power.power(action).subscribe())
}

logout() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Component, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-core'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { take, timer } from 'rxjs'

const COUNTDOWN = 30

@Component({
template: `
<p>
{{
'A backup is currently running. Interrupting it now can corrupt the backup of the service being written.'
| i18n
}}
</p>
@if (action === 'shutdown') {
<p>
{{
'Are you sure you want to power down your server? This can take several minutes, and your server will not come back online automatically. To power on again, You will need to physically unplug your server and plug it back in.'
| i18n
}}
</p>
}
<footer class="g-buttons">
<button tuiButton appearance="secondary" (click)="now()">
{{ (action === 'restart' ? 'Restart now' : 'Shut down now') | i18n }}
</button>
<button tuiButton (click)="wait()">
{{ 'Wait for backup to complete' | i18n }} ({{ seconds() }})
</button>
</footer>
`,
imports: [TuiButton, i18nPipe],
})
export class PowerComponent {
private readonly context =
injectContext<TuiDialogContext<boolean, T.PowerAction>>()

protected readonly action = this.context.data
protected readonly seconds = signal(COUNTDOWN)

constructor() {
// One timer, so the choice is made exactly when the label says it will be.
timer(0, 1000)
.pipe(take(COUNTDOWN + 1), takeUntilDestroyed())
.subscribe(tick => {
this.seconds.set(COUNTDOWN - tick)
if (tick === COUNTDOWN) this.wait()
})
}

protected now() {
this.context.completeWith(true)
}

protected wait() {
this.context.completeWith(false)
}
}

export const POWER = new PolymorpheusComponent(PowerComponent)
49 changes: 38 additions & 11 deletions projects/start-os/web/ui/src/app/routes/portal/portal.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Component, inject, signal } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterOutlet } from '@angular/router'
import { WA_IS_MOBILE } from '@ng-web-apis/platform'
import { i18nPipe, LeafProgressPipe, TaskService } from '@start9labs/shared'
import { i18nPipe, LeafProgressPipe } from '@start9labs/shared'
import {
TuiButton,
TuiCell,
Expand All @@ -14,10 +14,10 @@ import {
import { TuiActionBar, TuiProgress } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { TabsComponent } from 'src/app/routes/portal/components/tabs.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { OSService } from 'src/app/services/os.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { PluginsService } from 'src/app/services/plugins.service'
import { PowerService } from 'src/app/services/power.service'
import { HeaderComponent } from './components/header/header.component'

@Component({
Expand All @@ -30,7 +30,32 @@ import { HeaderComponent } from './components/header/header.component'
</tui-scrollbar>
</main>
<app-tabs />
@if (update(); as update) {
@if (deferredPower(); as action) {
<tui-action-bar *tuiPopup="true">
<span tuiCell="m">
<tui-icon icon="@tui.power" />
@if (action === 'restart') {
{{
'A backup is running. Your server will restart when it finishes.'
| i18n
}}
} @else {
{{
'A backup is running. Your server will shut down when it finishes.'
| i18n
}}
}
</span>
<button
tuiButton
size="s"
appearance="secondary"
(click)="power.cancel()"
>
{{ 'Cancel' | i18n }}
</button>
</tui-action-bar>
} @else if (update(); as update) {
<tui-action-bar *tuiPopup="bar()">
<span tuiCell="m">
@let leaf = update.overall | leafProgress;
Expand All @@ -49,8 +74,7 @@ import { HeaderComponent } from './components/header/header.component'
}
</span>
</tui-action-bar>
}
@if (restartReason(); as reason) {
} @else if (restartReason(); as reason) {
<tui-action-bar *tuiPopup="bar()">
<span tuiCell="m">
<tui-icon icon="@tui.refresh-cw" />
Expand Down Expand Up @@ -167,26 +191,29 @@ import { HeaderComponent } from './components/header/header.component'
],
})
export class PortalComponent {
private readonly tasks = inject(TaskService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
protected readonly power = inject(PowerService)

readonly mobile = inject(WA_IS_MOBILE)
readonly plugins = inject(PluginsService)
readonly update = toSignal(inject(OSService).updating$)
readonly restartReason = toSignal(
this.patch.watch$('serverInfo', 'statusInfo', 'restart'),
)
readonly deferredPower = toSignal(
this.patch.watch$('serverInfo', 'statusInfo', 'deferredPowerAction'),
)
readonly bar = signal(true)

getProgress(size: number, downloaded: number): number {
return Math.round((100 * downloaded) / (size || 1))
}

restart() {
this.tasks.run(async () => {
this.bar.set(false)
await this.api.restartServer({})
}, 'Beginning restart')
// Only stop offering the restart once one is actually under way — a
// deferred or dismissed one leaves the reason for this bar in place.
this.power.power('restart').subscribe(deferred => {
if (!deferred) this.bar.set(false)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ 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 { PowerService } from 'src/app/services/power.service'
import { TitleDirective } from 'src/app/services/title.service'
import { KeyboardSelectComponent } from './keyboard-select.component'
import { ServerNameDialog } from './server-name.dialog'
Expand Down Expand Up @@ -281,6 +282,7 @@ export default class SystemGeneralComponent {
private readonly injector = inject(INJECTOR)
private readonly win = inject(WA_WINDOW)
private readonly config = inject(ConfigService)
private readonly power = inject(PowerService)

count = 0

Expand Down Expand Up @@ -525,9 +527,6 @@ export default class SystemGeneralComponent {
}

private async restart() {
this.tasks.run(
async () => await this.api.restartServer({}),
'Beginning restart',
)
this.power.power('restart').subscribe()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export namespace Mock {
restarting: false,
shuttingDown: false,
restart: null,
deferredPowerAction: null,
}

export const RegistryOSUpdate: T.OsVersionInfoMap = {
Expand Down
Loading