server down handling
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
use reqwest::StatusCode;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::time::{sleep, Instant};
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
remotes::health::HealthRemote,
|
||||
remotes::health::{HealthError, HealthRemote},
|
||||
services::{
|
||||
auth::{get_access_token, get_tokens},
|
||||
health_manager::open_health_manager_window,
|
||||
health_manager::show_health_manager_with_error,
|
||||
scene::{close_splash_window, open_scene_window, open_splash_window},
|
||||
welcome::open_welcome_window,
|
||||
ws::init_ws_client,
|
||||
@@ -18,7 +18,10 @@ use crate::{
|
||||
|
||||
pub async fn start_fdoll() {
|
||||
init_system_tray();
|
||||
init_startup_sequence().await;
|
||||
if let Err(err) = init_startup_sequence().await {
|
||||
tracing::error!("startup sequence failed: {err}");
|
||||
show_health_manager_with_error(Some(err.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
async fn init_ws_after_auth() {
|
||||
@@ -74,21 +77,41 @@ pub async fn bootstrap() {
|
||||
|
||||
/// Perform checks for environment, network condition
|
||||
/// and handle situations where startup would not be appropriate.
|
||||
async fn init_startup_sequence() {
|
||||
let health_remote = HealthRemote::new();
|
||||
let server_health = health_remote.get_health().await;
|
||||
match server_health {
|
||||
Ok(response) => {
|
||||
if response.status == "OK" {
|
||||
async fn init_startup_sequence() -> Result<(), HealthError> {
|
||||
let health_remote = HealthRemote::try_new()?;
|
||||
|
||||
// simple retry loop to smooth transient network issues
|
||||
const MAX_ATTEMPTS: u8 = 3;
|
||||
const BACKOFF_MS: u64 = 500;
|
||||
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match health_remote.get_health().await {
|
||||
Ok(_) => {
|
||||
bootstrap().await;
|
||||
} else {
|
||||
info!("Server health check failed");
|
||||
return Ok(());
|
||||
}
|
||||
Err(HealthError::NonOkStatus(status)) => {
|
||||
warn!(attempt, "server health reported non-OK status: {status}");
|
||||
return Err(HealthError::NonOkStatus(status));
|
||||
}
|
||||
Err(HealthError::UnexpectedStatus(status)) => {
|
||||
warn!(attempt, "server health check failed with status: {status}");
|
||||
return Err(HealthError::UnexpectedStatus(status));
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(attempt, "server health check failed: {err}");
|
||||
if attempt == MAX_ATTEMPTS {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
info!("Server health check failed: {}", err);
|
||||
open_health_manager_window();
|
||||
close_splash_window();
|
||||
|
||||
if attempt < MAX_ATTEMPTS {
|
||||
sleep(Duration::from_millis(BACKOFF_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
Err(HealthError::UnexpectedStatus(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ fn quit_app() -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restart_app() -> Result<(), String> {
|
||||
fn restart_app() {
|
||||
let app_handle = get_app_handle();
|
||||
app_handle.restart();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use reqwest::{Client, Error};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{lock_r, state::FDOLL};
|
||||
@@ -14,34 +15,66 @@ pub struct HealthResponseDto {
|
||||
pub db: String,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum HealthError {
|
||||
#[error("app configuration missing {0}")]
|
||||
ConfigMissing(&'static str),
|
||||
#[error("health request failed: {0}")]
|
||||
Request(reqwest::Error),
|
||||
#[error("unexpected health status: {0}")]
|
||||
UnexpectedStatus(StatusCode),
|
||||
#[error("health status reported not OK: {0}")]
|
||||
NonOkStatus(String),
|
||||
#[error("health response decode failed: {0}")]
|
||||
Decode(reqwest::Error),
|
||||
}
|
||||
|
||||
pub struct HealthRemote {
|
||||
pub base_url: String,
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl HealthRemote {
|
||||
pub fn new() -> Self {
|
||||
pub fn try_new() -> Result<Self, HealthError> {
|
||||
let guard = lock_r!(FDOLL);
|
||||
Self {
|
||||
base_url: guard
|
||||
.app_config
|
||||
.api_base_url
|
||||
.as_ref()
|
||||
.expect("App configuration error")
|
||||
.clone(),
|
||||
client: guard
|
||||
.clients
|
||||
.as_ref()
|
||||
.expect("App configuration error")
|
||||
.http_client
|
||||
.clone(),
|
||||
}
|
||||
let base_url = guard
|
||||
.app_config
|
||||
.api_base_url
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or(HealthError::ConfigMissing("api_base_url"))?;
|
||||
|
||||
let client = guard
|
||||
.clients
|
||||
.as_ref()
|
||||
.map(|c| c.http_client.clone())
|
||||
.ok_or(HealthError::ConfigMissing("http_client"))?;
|
||||
|
||||
Ok(Self { base_url, client })
|
||||
}
|
||||
|
||||
pub async fn get_health(&self) -> Result<HealthResponseDto, Error> {
|
||||
pub async fn get_health(&self) -> Result<HealthResponseDto, HealthError> {
|
||||
let url = format!("{}/health", self.base_url);
|
||||
let resp = self.client.get(url).send().await?;
|
||||
let health = resp.json().await?;
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(HealthError::Request)?;
|
||||
|
||||
let resp = resp.error_for_status().map_err(|err| {
|
||||
err.status()
|
||||
.map(HealthError::UnexpectedStatus)
|
||||
.unwrap_or_else(|| HealthError::Request(err))
|
||||
})?;
|
||||
|
||||
let health: HealthResponseDto = resp.json().await.map_err(HealthError::Decode)?;
|
||||
|
||||
if health.status != "OK" {
|
||||
return Err(HealthError::NonOkStatus(health.status));
|
||||
}
|
||||
|
||||
Ok(health)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use tracing::{error, info};
|
||||
|
||||
use crate::get_app_handle;
|
||||
|
||||
static APP_MENU_WINDOW_LABEL: &str = "app_menu";
|
||||
pub static APP_MENU_WINDOW_LABEL: &str = "app_menu";
|
||||
|
||||
pub fn open_app_menu_window() {
|
||||
let app_handle = get_app_handle();
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
use crate::get_app_handle;
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder, MessageDialogKind};
|
||||
use tauri_plugin_positioner::WindowExt;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub static HEALTH_MANAGER_WINDOW_LABEL: &str = "health_manager";
|
||||
pub static HEALTH_MANAGER_EVENT: &str = "health-error";
|
||||
|
||||
pub fn open_health_manager_window() {
|
||||
fn close_window_if_exists(label: &str) {
|
||||
let app_handle = get_app_handle();
|
||||
if let Some(window) = app_handle.get_window(label) {
|
||||
if let Err(e) = window.close() {
|
||||
error!("Failed to close {} window: {}", label, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes primary UI windows and shows the health manager with an optional error message.
|
||||
pub fn show_health_manager_with_error(error_message: Option<String>) {
|
||||
let app_handle = get_app_handle();
|
||||
// Ensure other windows are closed before showing health manager
|
||||
close_window_if_exists(crate::services::scene::SPLASH_WINDOW_LABEL);
|
||||
close_window_if_exists(crate::services::scene::SCENE_WINDOW_LABEL);
|
||||
close_window_if_exists(crate::services::app_menu::APP_MENU_WINDOW_LABEL);
|
||||
|
||||
let existing_webview_window = app_handle.get_window(HEALTH_MANAGER_WINDOW_LABEL);
|
||||
|
||||
if let Some(window) = existing_webview_window {
|
||||
@@ -21,6 +37,12 @@ pub fn open_health_manager_window() {
|
||||
.kind(MessageDialogKind::Error)
|
||||
.show(|_| {});
|
||||
}
|
||||
|
||||
if let Some(message) = error_message {
|
||||
if let Err(e) = window.emit(HEALTH_MANAGER_EVENT, message.clone()) {
|
||||
error!("Failed to emit health error event: {}", e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,6 +80,12 @@ pub fn open_health_manager_window() {
|
||||
error!("Failed to move health manager window to center: {}", e);
|
||||
}
|
||||
|
||||
if let Some(message) = error_message {
|
||||
if let Err(e) = webview_window.emit(HEALTH_MANAGER_EVENT, message.clone()) {
|
||||
error!("Failed to emit health error event: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = webview_window.show() {
|
||||
error!("Failed to show health manager window: {}", e);
|
||||
MessageDialogBuilder::new(
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::{
|
||||
get_app_handle, lock_r, lock_w,
|
||||
models::app_config::AppConfig,
|
||||
services::cursor::{normalized_to_absolute, CursorPosition, CursorPositions},
|
||||
services::health_manager::{close_health_manager_window, show_health_manager_with_error},
|
||||
services::scene::open_scene_window,
|
||||
state::{init_app_data_scoped, AppDataRefreshScope, FDOLL},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -74,6 +76,10 @@ fn on_initialized(payload: Payload, _socket: RawClient) {
|
||||
if let Some(clients) = guard.clients.as_mut() {
|
||||
clients.is_ws_initialized = true;
|
||||
}
|
||||
|
||||
// Connection restored: close health manager and reopen scene
|
||||
close_health_manager_window();
|
||||
open_scene_window();
|
||||
} else {
|
||||
info!("Received initialized event with empty payload");
|
||||
}
|
||||
@@ -385,8 +391,20 @@ pub async fn report_cursor_data(cursor_position: CursorPosition) {
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => (),
|
||||
Ok(Err(e)) => error!("Failed to emit cursor report: {}", e),
|
||||
Err(e) => error!("Failed to execute blocking task for cursor report: {}", e),
|
||||
Ok(Err(e)) => {
|
||||
error!("Failed to emit cursor report: {}", e);
|
||||
show_health_manager_with_error(Some(format!(
|
||||
"WebSocket emit failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to execute blocking task for cursor report: {}", e);
|
||||
show_health_manager_with_error(Some(format!(
|
||||
"WebSocket task failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
let errorMessage = "";
|
||||
let unlisten: (() => void) | null = null;
|
||||
let isRestarting = false;
|
||||
|
||||
onMount(async () => {
|
||||
unlisten = await listen<string>("health-error", (event) => {
|
||||
errorMessage = event.payload;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
});
|
||||
|
||||
const tryAgain = async () => {
|
||||
if (isRestarting) return;
|
||||
isRestarting = true;
|
||||
errorMessage = "";
|
||||
try {
|
||||
await invoke("restart_app");
|
||||
} catch (err) {
|
||||
errorMessage = `Restart failed: ${err}`;
|
||||
isRestarting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="size-full p-4">
|
||||
@@ -9,13 +39,21 @@
|
||||
<p class="opacity-70 text-3xl font-bold">
|
||||
Seems like the server is inaccessible. Check your network?
|
||||
</p>
|
||||
{#if errorMessage}
|
||||
<p class="text-sm opacity-70 wrap-break-word">{errorMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
onclick={() => {
|
||||
console.log("Retrying server health detection");
|
||||
invoke("restart_app");
|
||||
}}>Try again</button
|
||||
class:btn-disabled={isRestarting}
|
||||
disabled={isRestarting}
|
||||
onclick={tryAgain}
|
||||
>
|
||||
{#if isRestarting}
|
||||
Retrying…
|
||||
{:else}
|
||||
Try again
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user