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

@@ -125,10 +125,10 @@ impl Public {
},
status_info: ServerStatus {
backup_progress: None,
updated: false,
update_progress: None,
shutting_down: false,
restarting: false,
restart: None,
},
unread_notification_count: 0,
password_hash: account.password.clone(),
@@ -220,6 +220,16 @@ pub struct ServerInfo {
pub keyboard: Option<KeyboardOptions>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum RestartReason {
Mdns,
Language,
Kiosk,
Update,
}
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
#[serde(rename_all = "camelCase")]
#[model = "Model<Self>"]
@@ -364,12 +374,13 @@ pub struct BackupProgress {
#[ts(export)]
pub struct ServerStatus {
pub backup_progress: Option<BTreeMap<PackageId, BackupProgress>>,
pub updated: bool,
pub update_progress: Option<FullProgress>,
#[serde(default)]
pub shutting_down: bool,
#[serde(default)]
pub restarting: bool,
#[serde(default)]
pub restart: Option<RestartReason>,
}
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]

View File

@@ -7,7 +7,7 @@ use tracing::instrument;
use ts_rs::TS;
use crate::context::RpcContext;
use crate::db::model::public::ServerInfo;
use crate::db::model::public::{RestartReason, ServerInfo};
use crate::prelude::*;
use crate::util::Invoke;
@@ -272,6 +272,7 @@ pub async fn set_hostname_rpc(
}
if let Some(hostname) = &hostname {
hostname.save(server_info)?;
server_info.as_status_info_mut().as_restart_mut().ser(&Some(RestartReason::Mdns))?;
}
ServerHostnameInfo::load(server_info)
})

View File

@@ -371,11 +371,11 @@ pub async fn init(
let ram = get_mem_info().await?.total.0 as u64 * 1024 * 1024;
let devices = lshw().await?;
let status_info = ServerStatus {
updated: false,
update_progress: None,
backup_progress: None,
shutting_down: false,
restarting: false,
restart: None,
};
db.mutate(|v| {
let server_info = v.as_public_mut().as_server_info_mut();

View File

@@ -16,6 +16,7 @@ use ts_rs::TS;
use crate::bins::set_locale;
use crate::context::{CliContext, RpcContext};
use crate::db::model::public::RestartReason;
use crate::disk::util::{get_available, get_used};
use crate::logs::{LogSource, LogsParams, SYSTEM_UNIT};
use crate::prelude::*;
@@ -351,10 +352,9 @@ pub fn kiosk<C: Context>() -> ParentHandler<C> {
from_fn_async(|ctx: RpcContext| async move {
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_server_info_mut()
.as_kiosk_mut()
.ser(&Some(true))
let server_info = db.as_public_mut().as_server_info_mut();
server_info.as_kiosk_mut().ser(&Some(true))?;
server_info.as_status_info_mut().as_restart_mut().ser(&Some(RestartReason::Kiosk))
})
.await
.result?;
@@ -369,10 +369,9 @@ pub fn kiosk<C: Context>() -> ParentHandler<C> {
from_fn_async(|ctx: RpcContext| async move {
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_server_info_mut()
.as_kiosk_mut()
.ser(&Some(false))
let server_info = db.as_public_mut().as_server_info_mut();
server_info.as_kiosk_mut().ser(&Some(false))?;
server_info.as_status_info_mut().as_restart_mut().ser(&Some(RestartReason::Kiosk))
})
.await
.result?;
@@ -1367,10 +1366,11 @@ pub async fn set_language(
save_language(&*language).await?;
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_server_info_mut()
let server_info = db.as_public_mut().as_server_info_mut();
server_info
.as_language_mut()
.ser(&Some(language.clone()))
.ser(&Some(language.clone()))?;
server_info.as_status_info_mut().as_restart_mut().ser(&Some(RestartReason::Language))
})
.await
.result?;

View File

@@ -19,6 +19,7 @@ use ts_rs::TS;
use crate::PLATFORM;
use crate::context::{CliContext, RpcContext};
use crate::db::model::public::RestartReason;
use crate::notifications::{NotificationLevel, notify};
use crate::prelude::*;
use crate::progress::{
@@ -81,8 +82,9 @@ pub async fn update_system(
.into_public()
.into_server_info()
.into_status_info()
.into_updated()
.into_restart()
.de()?
== Some(RestartReason::Update)
{
return Err(Error::new(
eyre!("{}", t!("update.already-updated-restart-required")),
@@ -281,10 +283,18 @@ async fn maybe_do_update(
let start_progress = progress.snapshot();
let status = ctx
.db
ctx.db
.mutate(|db| {
let mut status = peeked.as_public().as_server_info().as_status_info().de()?;
let server_info = db.as_public_mut().as_server_info_mut();
if server_info.as_status_info().as_restart().de()?.is_some() {
return Err(Error::new(
eyre!("{}", t!("update.already-updated-restart-required")),
crate::ErrorKind::InvalidRequest,
));
}
let mut status = server_info.as_status_info().de()?;
if status.update_progress.is_some() {
return Err(Error::new(
eyre!("{}", t!("update.already-updating")),
@@ -293,22 +303,12 @@ async fn maybe_do_update(
}
status.update_progress = Some(start_progress);
db.as_public_mut()
.as_server_info_mut()
.as_status_info_mut()
.ser(&status)?;
Ok(status)
server_info.as_status_info_mut().ser(&status)?;
Ok(())
})
.await
.result?;
if status.updated {
return Err(Error::new(
eyre!("{}", t!("update.already-updated-restart-required")),
crate::ErrorKind::InvalidRequest,
));
}
let progress_task = NonDetachingJoinHandle::from(tokio::spawn(progress.clone().sync_to_db(
ctx.db.clone(),
|db| {
@@ -338,10 +338,15 @@ async fn maybe_do_update(
Ok(()) => {
ctx.db
.mutate(|db| {
let status_info =
db.as_public_mut().as_server_info_mut().as_status_info_mut();
status_info.as_update_progress_mut().ser(&None)?;
status_info.as_updated_mut().ser(&true)
let server_info = db.as_public_mut().as_server_info_mut();
server_info
.as_status_info_mut()
.as_update_progress_mut()
.ser(&None)?;
server_info
.as_status_info_mut()
.as_restart_mut()
.ser(&Some(RestartReason::Update))
})
.await
.result?;

View File

@@ -28,7 +28,14 @@ impl VersionT for Version {
&V0_3_0_COMPAT
}
#[instrument(skip_all)]
fn up(self, _db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
let status_info = db["public"]["serverInfo"]["statusInfo"]
.as_object_mut();
if let Some(m) = status_info {
m.remove("updated");
m.insert("restart".into(), Value::Null);
}
Ok(Value::Null)
}
fn down(self, _db: &mut Value) -> Result<(), Error> {