fix: try fix install jobs being bricked/improve tracing (#6890)

* fix: try fix install jobs being bricked/improve tracing

* fix: rev cmts

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-27 14:47:46 +00:00
committed by GitHub
co-authored by Prospector
parent 372e2bf47b
commit 4c2e64e31f
13 changed files with 583 additions and 290 deletions
+14 -5
View File
@@ -202,11 +202,20 @@ pub async fn emit_install_job(
{
use tauri::Emitter;
let event_state = crate::EventState::get()?;
event_state
.app
.emit("install_job", snapshot)
.map_err(crate::event::EventError::from)?;
let result: crate::Result<()> = (|| {
let event_state = crate::EventState::get()?;
event_state
.app
.emit("install_job", snapshot)
.map_err(crate::event::EventError::from)?;
Ok(())
})();
if let Err(error) = result {
tracing::warn!(
"Failed to emit install job {} update: {error}",
snapshot.job_id
);
}
}
Ok(())
+75 -37
View File
@@ -262,29 +262,51 @@ async fn copy_symlink(source: &Path, target: &Path) -> crate::Result<()> {
pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
let jobs = store::list_interrupted_candidates(state).await?;
for mut job in jobs {
if job.state.display.is_none() {
job.state.display = display_from_request(&job.state);
for job in jobs {
let job_id = job.id;
if let Err(error) = recover_interrupted_job(job, state).await {
tracing::error!(
"Error recovering interrupted install job {job_id}: {error}"
);
}
let interrupted_phase = job.state.progress.phase;
job.state.record_event(InstallJobEventKind::Interrupted {
reason: InstallInterruptReason::AppClosed,
phase: interrupted_phase,
});
job.state.progress.phase = InstallPhaseId::RollingBack;
job.state.progress.progress = None;
job.state.progress.details = InstallPhaseDetails::Empty;
job.state.error = Some(InstallErrorView::from_message(
"app_closed",
interrupted_phase,
"App closed while install was running",
));
}
job.state
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
if let Err(error) = apply_cleanup(&job.state, state).await {
Ok(())
}
async fn recover_interrupted_job(
mut job: store::InstallJobRecord,
state: &State,
) -> crate::Result<()> {
if job.state.display.is_none() {
job.state.display = display_from_request(&job.state);
}
let interrupted_phase = job.state.progress.phase;
job.state.record_event(InstallJobEventKind::Interrupted {
reason: InstallInterruptReason::AppClosed,
phase: interrupted_phase,
});
job.state.progress.phase = InstallPhaseId::RollingBack;
job.state.progress.progress = None;
job.state.progress.details = InstallPhaseDetails::Empty;
job.state.error = Some(InstallErrorView::from_message(
"app_closed",
interrupted_phase,
"App closed while install was running",
));
job.state
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
let cleanup_succeeded = match apply_cleanup(&job.state, state).await {
Ok(()) => {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job.state);
true
}
Err(error) => {
tracing::error!(
"Error cleaning up interrupted install job {}: {error}",
job.id
@@ -298,20 +320,19 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
} else {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
false
}
clear_deleted_new_instance_id(&mut job.state);
};
let record = store::update_status(
job.id,
InstallJobStatus::Interrupted,
&job.state,
state,
)
.await?;
if job.state.rollback_error.is_none() {
if let Some(record) = store::finish_active(
job.id,
InstallJobStatus::Interrupted,
&job.state,
state,
)
.await?
{
if cleanup_succeeded {
clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
@@ -383,10 +404,20 @@ pub async fn apply_cleanup(
match &job_state.cleanup {
InstallCleanup::DeleteNewInstance { instance_id } => {
if let Some(instance_id) = instance_id {
let _ = crate::state::remove_instance(instance_id, state).await;
let _ =
if crate::state::get_instance(instance_id, &state.pool)
.await?
.is_some()
{
crate::state::remove_instance(instance_id, state).await?;
}
if let Err(error) =
emit_instance(instance_id, InstancePayloadType::Removed)
.await;
.await
{
tracing::warn!(
"Failed to emit removed instance {instance_id}: {error}"
);
}
}
}
InstallCleanup::RestoreExistingInstance { instance_id } => {
@@ -410,7 +441,14 @@ pub async fn apply_cleanup(
)
.await?;
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
if let Err(error) =
emit_instance(instance_id, InstancePayloadType::Edited)
.await
{
tracing::warn!(
"Failed to emit restored instance {instance_id}: {error}"
);
}
}
}
}
+254 -114
View File
@@ -163,19 +163,57 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
job.state.progress.phase = InstallPhaseId::PreparingInstance;
job.state.progress.progress = None;
job.state.progress.details = InstallPhaseDetails::Empty;
prepare_initial_instance(&mut job.state, &state).await?;
if let Err(error) = prepare_initial_instance(&mut job.state, &state).await {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job.state, &state).await
{
tracing::error!(
"Error cleaning up install job {job_id} retry preparation: {cleanup_error}"
);
}
return Err(error);
}
job.state.record_event(InstallJobEventKind::JobQueued {
kind: job.state.request.kind(),
});
let record = store::update_status(
let record = match store::update_status(
job_id,
InstallJobStatus::Queued,
&job.state,
&state,
)
.await?;
lock_existing_instance_if_needed(&job.state, &state).await?;
.await
{
Ok(record) => record,
Err(error) => {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job.state, &state).await
{
tracing::error!(
"Error cleaning up unqueued install job {job_id}: {cleanup_error}"
);
}
return Err(error);
}
};
if let Err(error) =
lock_existing_instance_if_needed(&job.state, &state).await
{
let error_view = install_error_view(
job.state.progress.phase,
&error,
job.state.context.clone(),
);
if let Err(terminal_error) =
terminalize_failed_job(job_id, job.state, error_view, &state).await
{
tracing::error!(
"Failed to terminalize retried install job {job_id}: {terminal_error}"
);
}
return Err(error);
}
emit_install_job(&record.snapshot()).await?;
spawn_job(job_id);
@@ -206,31 +244,50 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
match recovery::apply_cleanup(&job.state, &state).await {
Ok(()) => job
.state
.record_event(InstallJobEventKind::RollbackCompleted),
Err(error) => {
job.state.rollback_error = Some(InstallErrorView::from_error(
"rollback_error",
InstallPhaseId::RollingBack,
&error,
None,
));
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
}
}
clear_deleted_new_instance_id(&mut job.state);
let record = store::update_status(
let status_updated = store::update_status_if(
job_id,
InstallJobStatus::Canceled,
InstallJobStatus::Queued,
InstallJobStatus::Running,
&job.state,
&state,
)
.await?;
if job.state.rollback_error.is_none() {
.await?
.is_some();
if !status_updated {
return Err(crate::ErrorKind::InputError(
"Install job is no longer queued".to_string(),
)
.into());
}
let cleanup_succeeded =
match recovery::apply_cleanup(&job.state, &state).await {
Ok(()) => {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job.state);
true
}
Err(error) => {
job.state.rollback_error = Some(InstallErrorView::from_error(
"rollback_error",
InstallPhaseId::RollingBack,
&error,
None,
));
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
false
}
};
let status = if cleanup_succeeded {
InstallJobStatus::Canceled
} else {
InstallJobStatus::Failed
};
let record =
store::update_status(job_id, status, &job.state, &state).await?;
if cleanup_succeeded {
recovery::clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
@@ -247,10 +304,53 @@ async fn start(request: InstallRequest) -> crate::Result<InstallJobSnapshot> {
let state = State::get().await?;
let id = Uuid::new_v4();
let mut job_state = InstallJobState::new(request);
prepare_initial_instance(&mut job_state, &state).await?;
let record =
store::insert(id, &job_state, InstallJobStatus::Queued, &state).await?;
lock_existing_instance_if_needed(&job_state, &state).await?;
if let Err(error) = prepare_initial_instance(&mut job_state, &state).await {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error cleaning up install job preparation: {cleanup_error}"
);
}
return Err(error);
}
let record = match store::insert(
id,
&job_state,
InstallJobStatus::Queued,
&state,
)
.await
{
Ok(record) => record,
Err(error) => {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error cleaning up untracked install job {id}: {cleanup_error}"
);
}
return Err(error);
}
};
if let Err(error) =
lock_existing_instance_if_needed(&job_state, &state).await
{
let error_view = install_error_view(
job_state.progress.phase,
&error,
job_state.context.clone(),
);
if let Err(terminal_error) =
terminalize_failed_job(id, job_state, error_view, &state).await
{
tracing::error!(
"Failed to terminalize install job {id} after setup error: {terminal_error}"
);
}
return Err(error);
}
emit_install_job(&record.snapshot()).await?;
spawn_job(id);
Ok(record.snapshot())
@@ -370,9 +470,9 @@ async fn prepare_initial_instance(
metadata.instance.icon_path,
);
let instance_id = metadata.instance.id;
set_instance_id(job_state, instance_id.clone());
attach_pending_shared_instance(&instance_id, &data, state).await?;
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
set_instance_id(job_state, instance_id);
}
InstallRequest::ImportInstance {
instance_folder, ..
@@ -433,9 +533,14 @@ async fn prepare_initial_instance(
fn spawn_job(job_id: Uuid) {
tokio::spawn(async move {
if let Err(error) = Box::pin(run_job(job_id)).await {
tracing::error!(
"Install job {job_id} failed to update state: {error}"
);
let failure = error.to_string();
tracing::error!("Install job {job_id} terminated: {failure}");
if let Err(error) = terminalize_stranded_job(job_id, failure).await
{
tracing::error!(
"Failed to terminalize stranded install job {job_id}: {error}"
);
}
}
});
}
@@ -457,13 +562,17 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
let mut job_state = job.state.clone();
job_state.record_event(InstallJobEventKind::JobStarted);
let record = store::update_status(
let Some(record) = store::update_status_if(
job_id,
InstallJobStatus::Queued,
InstallJobStatus::Running,
&job_state,
&state,
)
.await?;
.await?
else {
return Ok(());
};
emit_install_job(&record.snapshot()).await?;
let result = Box::pin(run_request(job_id, &mut job_state, &state)).await;
@@ -472,19 +581,21 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
}
let result = match result {
Ok(instance_id) => {
if let Some(instance_id) = instance_id {
set_instance_id(&mut job_state, instance_id);
}
finalize_existing_instance_success(&job_state, &state).await
Ok(Some(instance_id)) => {
set_instance_id(&mut job_state, instance_id.clone());
Ok(instance_id)
}
Ok(None) => Err(crate::ErrorKind::InputError(
"Install job completed without an instance id".to_string(),
)
.into()),
Err(error) => Err(error),
};
match result {
Ok(()) => {
Ok(instance_id) => {
job_state.record_event(InstallJobEventKind::JobSucceeded {
instance_id: current_instance_id(&job_state),
instance_id: Some(instance_id.clone()),
});
job_state.progress.phase = InstallPhaseId::Finalizing;
job_state.progress.progress = None;
@@ -492,71 +603,119 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
job_state.error = None;
job_state.rollback_error = None;
job_state.context = None;
let record = store::update_status(
job_id,
InstallJobStatus::Succeeded,
&job_state,
&state,
)
.await?;
recovery::clear_staging_dir(&job_state).await;
emit_install_job(&record.snapshot()).await?;
if let Some(record) =
store::complete_success(job_id, &job_state, &state).await?
{
recovery::clear_staging_dir(&job_state).await;
if let Err(error) =
emit_instance(&instance_id, InstancePayloadType::Edited)
.await
{
tracing::warn!(
"Failed to emit completed instance {instance_id}: {error}"
);
}
emit_install_job(&record.snapshot()).await?;
}
}
Err(error) => {
let failed_phase = job_state.progress.phase;
tracing::error!("Install job {job_id} failed: {error}");
let error_view = install_error_view(
failed_phase,
job_state.progress.phase,
&error,
job_state.context.clone(),
);
job_state.record_event(InstallJobEventKind::Failed {
phase: failed_phase,
code: error_view.code.clone(),
message: error_view.message.clone(),
});
job_state.error = Some(error_view);
job_state.progress.phase = InstallPhaseId::RollingBack;
job_state.progress.progress = None;
job_state.progress.details = InstallPhaseDetails::Empty;
job_state.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job_state.cleanup.clone(),
});
if let Err(rollback_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error rolling back failed install job {job_id}: {rollback_error}"
);
job_state.rollback_error = Some(install_error_view(
InstallPhaseId::RollingBack,
&rollback_error,
None,
));
job_state.record_event(InstallJobEventKind::RollbackFailed {
message: rollback_error.to_string(),
});
} else {
job_state.record_event(InstallJobEventKind::RollbackCompleted);
}
clear_deleted_new_instance_id(&mut job_state);
let record = store::update_status(
job_id,
InstallJobStatus::Failed,
&job_state,
&state,
)
.await?;
if job_state.rollback_error.is_none() {
recovery::clear_staging_dir(&job_state).await;
}
emit_install_job(&record.snapshot()).await?;
return Err(error);
terminalize_failed_job(job_id, job_state, error_view, &state)
.await?;
}
}
Ok(())
}
async fn terminalize_stranded_job(
job_id: Uuid,
message: String,
) -> crate::Result<()> {
let state = State::get().await?;
let job = store::get_required(job_id, &state).await?;
if !matches!(
job.status,
InstallJobStatus::Queued | InstallJobStatus::Running
) {
return Ok(());
}
let error_view = InstallErrorView::from_message(
"install_worker_terminated",
job.state.progress.phase,
message,
);
terminalize_failed_job(job_id, job.state, error_view, &state).await
}
async fn terminalize_failed_job(
job_id: Uuid,
mut job_state: InstallJobState,
error_view: InstallErrorView,
state: &State,
) -> crate::Result<()> {
let failed_phase = job_state.progress.phase;
job_state.record_event(InstallJobEventKind::Failed {
phase: failed_phase,
code: error_view.code.clone(),
message: error_view.message.clone(),
});
job_state.error = Some(error_view);
job_state.rollback_error = None;
job_state.progress.phase = InstallPhaseId::RollingBack;
job_state.progress.progress = None;
job_state.progress.details = InstallPhaseDetails::Empty;
job_state.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job_state.cleanup.clone(),
});
let cleanup_succeeded = match recovery::apply_cleanup(&job_state, state)
.await
{
Ok(()) => {
job_state.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job_state);
true
}
Err(rollback_error) => {
tracing::error!(
"Error rolling back failed install job {job_id}: {rollback_error}"
);
job_state.rollback_error = Some(install_error_view(
InstallPhaseId::RollingBack,
&rollback_error,
None,
));
job_state.record_event(InstallJobEventKind::RollbackFailed {
message: rollback_error.to_string(),
});
false
}
};
if let Some(record) = store::finish_active(
job_id,
InstallJobStatus::Failed,
&job_state,
state,
)
.await?
{
if cleanup_succeeded {
recovery::clear_staging_dir(&job_state).await;
}
emit_install_job(&record.snapshot()).await?;
}
Ok(())
}
async fn run_request(
job_id: Uuid,
job_state: &mut InstallJobState,
@@ -1176,25 +1335,6 @@ async fn lock_existing_instance(
Ok(())
}
async fn finalize_existing_instance_success(
job_state: &InstallJobState,
state: &State,
) -> crate::Result<()> {
if let InstallCleanup::RestoreExistingInstance { instance_id } =
&job_state.cleanup
{
crate::state::instances::commands::set_instance_install_stage(
instance_id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
Ok(())
}
pub(super) async fn update_progress(
job_id: Uuid,
job_state: &mut InstallJobState,
+156 -2
View File
@@ -171,7 +171,7 @@ pub async fn list(
.await?
};
rows.into_iter().map(row_to_record).collect()
Ok(deserialize_rows(rows))
}
pub async fn list_interrupted_candidates(
@@ -198,7 +198,7 @@ pub async fn list_interrupted_candidates(
.fetch_all(&app_state.pool)
.await?;
rows.into_iter().map(row_to_record).collect()
Ok(deserialize_rows(rows))
}
pub async fn update_state(
@@ -262,6 +262,143 @@ pub async fn update_status(
get_required(id, app_state).await
}
pub async fn update_status_if(
id: Uuid,
expected_status: InstallJobStatus,
status: InstallJobStatus,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let now = Utc::now();
let finished = status.is_finished().then_some(now.timestamp());
let json = serde_json::to_string(state)?;
let status_value = status.as_str();
let expected_status_value = expected_status.as_str();
let instance_id = instance_id(state);
let id_value = id.to_string();
let modified = now.timestamp();
let result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, status = ?, state = ?, modified = ?, finished = ?
WHERE id = ? AND status = ?
",
)
.bind(instance_id)
.bind(status_value)
.bind(json)
.bind(modified)
.bind(finished)
.bind(id_value)
.bind(expected_status_value)
.execute(&app_state.pool)
.await?;
if result.rows_affected() == 0 {
return Ok(None);
}
get_required(id, app_state).await.map(Some)
}
pub async fn finish_active(
id: Uuid,
status: InstallJobStatus,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let now = Utc::now();
let finished = now.timestamp();
let json = serde_json::to_string(state)?;
let status_value = status.as_str();
let instance_id = instance_id(state);
let id_value = id.to_string();
let modified = finished;
let result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, status = ?, state = ?, modified = ?, finished = ?
WHERE id = ? AND status IN ('queued', 'running')
",
)
.bind(instance_id)
.bind(status_value)
.bind(json)
.bind(modified)
.bind(finished)
.bind(id_value)
.execute(&app_state.pool)
.await?;
if result.rows_affected() == 0 {
return Ok(None);
}
get_required(id, app_state).await.map(Some)
}
pub async fn complete_success(
id: Uuid,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let Some(instance_id) = instance_id(state) else {
return Err(crate::ErrorKind::InputError(
"Install job is missing its instance id".to_string(),
)
.into());
};
let now = Utc::now().timestamp();
let json = serde_json::to_string(state)?;
let id_value = id.to_string();
let mut transaction = app_state.pool.begin().await?;
let job_result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, status = 'succeeded', state = ?, modified = ?, finished = ?
WHERE id = ? AND status = 'running'
",
)
.bind(&instance_id)
.bind(json)
.bind(now)
.bind(now)
.bind(id_value)
.execute(&mut *transaction)
.await?;
if job_result.rows_affected() == 0 {
transaction.rollback().await?;
return Ok(None);
}
let instance_result = sqlx::query(
"
UPDATE instances
SET install_stage = 'installed', modified = ?
WHERE id = ?
",
)
.bind(now)
.bind(&instance_id)
.execute(&mut *transaction)
.await?;
if instance_result.rows_affected() == 0 {
transaction.rollback().await?;
return Err(crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
.into());
}
transaction.commit().await?;
get_required(id, app_state).await.map(Some)
}
pub async fn dismiss(id: Uuid, app_state: &State) -> crate::Result<()> {
let id = id.to_string();
let modified = Utc::now().timestamp();
@@ -308,6 +445,23 @@ fn row_to_record(row: InstallJobRow) -> crate::Result<InstallJobRecord> {
})
}
fn deserialize_rows(rows: Vec<InstallJobRow>) -> Vec<InstallJobRecord> {
rows.into_iter()
.filter_map(|row| {
let id = row.id.clone();
match row_to_record(row) {
Ok(record) => Some(record),
Err(error) => {
tracing::error!(
"Failed to deserialize install job {id}: {error}"
);
None
}
}
})
.collect()
}
fn instance_id(state: &InstallJobState) -> Option<String> {
match &state.target {
super::model::InstallTarget::NewInstance { instance_id } => {
+9 -7
View File
@@ -603,19 +603,21 @@ pub async fn install_minecraft_with_reporter(
let protocol_version = read_protocol_version_from_jar(client_path).await?;
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
crate::state::instances::commands::set_applied_content_set_protocol_version(
&instance.id,
protocol_version,
&state.pool,
)
.await?;
if reporter.is_none() {
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
}
if let Some(loading_bar) = &loading_bar {
emit_loading(loading_bar, 1.0, Some("Finished installing"))?;
}
+1
View File
@@ -65,6 +65,7 @@ pub enum FeatureFlag {
ShowInstancePlayTime,
SkipNonEssentialWarnings,
AdvancedFiltersCollapsed,
AlwaysShowCopyDetails,
}
impl Settings {
@@ -52,6 +52,7 @@
:progress-current="progressItem.progressCurrent"
:progress-total="progressItem.progressTotal"
:actions="progressItem.buttons"
:dismissible="progressItem.dismissible"
@dismiss="handleProgressItemDismiss(item, progressItem)"
@action="(index) => handleProgressItemAction(progressItem, index)"
/>
@@ -49,7 +49,7 @@
</template>
</template>
</p>
<ButtonStyled size="small" type="transparent" circular>
<ButtonStyled v-if="dismissible" size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
@@ -96,7 +96,7 @@
{{ entityLabel }}
</p>
<div class="col-start-2 row-start-1 justify-self-end">
<ButtonStyled size="small" type="transparent" circular>
<ButtonStyled v-if="dismissible" size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
@@ -213,6 +213,7 @@ const props = withDefaults(
progressCurrent?: number
progressTotal?: number
actions?: PopupNotificationButton[]
dismissible?: boolean
}>(),
{
actionLoading: null,
@@ -224,6 +225,7 @@ const props = withDefaults(
showProgress: true,
wrapText: false,
progressType: 'percentage',
dismissible: true,
},
)
@@ -24,6 +24,7 @@ export interface PopupNotificationProgressItem {
progressType?: PopupNotificationProgressType
progressCurrent?: number
progressTotal?: number
dismissible?: boolean
onDismiss?: () => void | Promise<void>
buttons?: PopupNotificationButton[]
}