v0.3.7-pre.004
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-backfill-desk/src/app_state.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
//! Shared backend state owned by the Backfill Desk Tauri application.
|
||||
|
||||
@@ -9,6 +9,8 @@ pub(crate) struct AppState {
|
||||
logging_runtime: std::sync::Mutex<LoggingRuntimeState>,
|
||||
splash_settings: crate::SplashSettings,
|
||||
splash_sequence_started: std::sync::atomic::AtomicBool,
|
||||
transport_runtime: std::option::Option<crate::TransportRuntime>,
|
||||
transport_startup_diagnostic: std::option::Option<crate::CommandErrorDto>,
|
||||
}
|
||||
|
||||
impl crate::AppState {
|
||||
@@ -29,6 +31,21 @@ impl crate::AppState {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let transport_startup = crate::initialize_transport(&config_management);
|
||||
let (transport_runtime, transport_startup_diagnostic) = match transport_startup {
|
||||
std::result::Result::Ok(value) => (std::option::Option::Some(value), std::option::Option::None),
|
||||
std::result::Result::Err(error) => {
|
||||
let diagnostic = crate::CommandErrorDto::from_error(&error);
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_TRANSPORT,
|
||||
error_domain = diagnostic.domain.as_str(),
|
||||
error_code = diagnostic.code.as_str(),
|
||||
"Backfill Desk Transport readiness is unavailable; keeping desktop shell available"
|
||||
);
|
||||
(std::option::Option::None, std::option::Option::Some(diagnostic))
|
||||
},
|
||||
};
|
||||
let splash_settings = crate::SplashSettings::load();
|
||||
let splash_settings = match splash_settings {
|
||||
std::result::Result::Ok(value) => value,
|
||||
@@ -65,6 +82,8 @@ impl crate::AppState {
|
||||
}),
|
||||
splash_settings,
|
||||
splash_sequence_started: std::sync::atomic::AtomicBool::new(false),
|
||||
transport_runtime,
|
||||
transport_startup_diagnostic,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,11 +116,25 @@ impl crate::AppState {
|
||||
application_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
config_document_count: document_count,
|
||||
fallback_logging_active: runtime.fallback_active,
|
||||
shell_phase: "pre.002-desktop-scaffold".to_owned(),
|
||||
shell_phase: "pre.004-transport-readiness".to_owned(),
|
||||
startup_diagnostic: runtime.startup_diagnostic.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds the safe Transport-readiness subset of Backfill Desk options.
|
||||
pub(crate) fn backfill_options(&self) -> ksp_core_lib::Result<crate::BackfillDeskOptionsDto> {
|
||||
let runtime = self.transport_runtime.as_ref();
|
||||
return match runtime {
|
||||
std::option::Option::Some(value) => value.options(),
|
||||
std::option::Option::None => std::result::Result::Ok(crate::BackfillDeskOptionsDto {
|
||||
compatible_roles: std::vec::Vec::new(),
|
||||
configured_networks: std::vec::Vec::new(),
|
||||
transport_diagnostic: self.transport_startup_diagnostic.clone(),
|
||||
transport_ready: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the resolved splash timings captured during application bootstrap.
|
||||
#[must_use]
|
||||
pub(crate) const fn splash_settings(&self) -> crate::SplashSettings {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-backfill-desk/src/constants.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Application-owned tracing targets and domains.
|
||||
|
||||
@@ -15,6 +15,8 @@ pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "backfill.bootstrap";
|
||||
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
|
||||
/// Structured domain used by the Backfill Desk shell.
|
||||
pub(crate) const TRACING_DOMAIN_SHELL: &str = "backfill.shell";
|
||||
/// Structured domain used by Transport readiness and role inventory operations.
|
||||
pub(crate) const TRACING_DOMAIN_TRANSPORT: &str = "backfill.transport";
|
||||
/// Structured domain used by Tauri window lifecycle operations.
|
||||
pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window";
|
||||
/// Owning target for backend events emitted by Backfill Desk.
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
// file: crates/ksp-app-backfill-desk/src/dto_common.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
//! Common Tauri DTOs shared by the Backfill Desk shell.
|
||||
|
||||
use ts_rs::TS; // rust-rules: trait-import
|
||||
|
||||
/// Safe Transport-readiness subset of the Backfill Desk options contract.
|
||||
///
|
||||
/// Campaign scopes, commitments and backend-owned bounds are added in the dedicated request/DTO slice. Endpoint URLs, provider identities and credentials
|
||||
/// are intentionally absent.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_common/BackfillDeskOptionsDto.ts")]
|
||||
pub(crate) struct BackfillDeskOptionsDto {
|
||||
/// Logical roles that can route both RPC methods required by the Backfill runtime.
|
||||
pub(crate) compatible_roles: std::vec::Vec<String>,
|
||||
/// Distinct enabled HTTP cluster labels selected by the active Transport profile.
|
||||
pub(crate) configured_networks: std::vec::Vec<String>,
|
||||
/// Safe startup diagnostic when Transport configuration could not be resolved or constructed.
|
||||
pub(crate) transport_diagnostic: std::option::Option<CommandErrorDto>,
|
||||
/// Whether Transport currently has one coherent network and at least one compatible available role.
|
||||
pub(crate) transport_ready: bool,
|
||||
}
|
||||
|
||||
/// Safe command error projection that never serializes arbitrary KSP error context or source values.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-backfill-desk/src/errors.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
//! Application-local error codes for Backfill Desk composition and desktop runtime surfaces.
|
||||
|
||||
@@ -8,17 +8,13 @@ pub(crate) const ERROR_CODE_APP_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_cor
|
||||
/// Shared Backfill Desk runtime state cannot be locked safely.
|
||||
pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "app_state_lock_failed");
|
||||
/// Backfill Desk composite configuration is missing or references an unexpected document.
|
||||
pub(crate) const ERROR_CODE_CONFIG_COMPOSITE_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("backfill_desk", "config_composite_invalid");
|
||||
pub(crate) const ERROR_CODE_CONFIG_COMPOSITE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "config_composite_invalid");
|
||||
/// Frontend logging requested an unsupported level.
|
||||
pub(crate) const ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("backfill_desk", "frontend_log_level_invalid");
|
||||
pub(crate) const ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "frontend_log_level_invalid");
|
||||
/// Frontend logging requested a target outside the application whitelist.
|
||||
pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("backfill_desk", "frontend_log_target_invalid");
|
||||
pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "frontend_log_target_invalid");
|
||||
/// Backfill Desk could not install the managed Logging runtime or its safe fallback.
|
||||
pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("backfill_desk", "logging_bootstrap_failed");
|
||||
pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "logging_bootstrap_failed");
|
||||
/// Splash readiness was invoked from a window other than the splash window.
|
||||
pub(crate) const ERROR_CODE_SPLASH_ORIGIN_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "splash_origin_invalid");
|
||||
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
|
||||
@@ -30,3 +26,5 @@ pub(crate) const ERROR_CODE_TAURI_WINDOW_MISSING: ksp_core_lib::ErrorCode = ksp_
|
||||
/// A Tauri window show/focus/destroy/event operation failed.
|
||||
pub(crate) const ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("backfill_desk", "tauri_window_operation_failed");
|
||||
/// Backfill Desk cannot derive a safe Transport readiness contract from the Transport registry.
|
||||
pub(crate) const ERROR_CODE_TRANSPORT_READINESS_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "transport_readiness_invalid");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-backfill-desk/src/lib.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Tauri desktop application scaffold for controlling and inspecting KSP RAW backfill jobs.
|
||||
|
||||
@@ -16,6 +16,7 @@ mod frontend_logging;
|
||||
mod logging_runtime;
|
||||
mod splash;
|
||||
mod tauri;
|
||||
mod transport_runtime;
|
||||
mod tw_main;
|
||||
mod tw_splash;
|
||||
|
||||
@@ -46,6 +47,8 @@ pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP;
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND;
|
||||
/// Structured domain used by the Backfill Desk shell.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_SHELL;
|
||||
/// Structured domain used by Transport readiness and role inventory operations.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_TRANSPORT;
|
||||
/// Structured domain used by Tauri window lifecycle operations.
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_WINDOWS;
|
||||
/// Owning target for backend events emitted by Backfill Desk.
|
||||
@@ -56,6 +59,8 @@ pub(crate) use self::constants::TRACING_TARGET_FRONTEND;
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN;
|
||||
/// Owning target for splash-window frontend events.
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_SPLASH;
|
||||
/// Safe Transport-readiness subset of the Backfill Desk options contract.
|
||||
pub(crate) use self::dto_common::BackfillDeskOptionsDto;
|
||||
/// Safe command error projection exposed to Tauri commands.
|
||||
pub(crate) use self::dto_common::CommandErrorDto;
|
||||
/// Safe scaffold/runtime snapshot exposed to the shell.
|
||||
@@ -82,6 +87,8 @@ pub(crate) use self::errors::ERROR_CODE_TAURI_RUNTIME_FAILED;
|
||||
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_MISSING;
|
||||
/// A Tauri window show/focus/destroy/event operation failed.
|
||||
pub(crate) use self::errors::ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED;
|
||||
/// Backfill Desk cannot derive a safe Transport readiness contract.
|
||||
pub(crate) use self::errors::ERROR_CODE_TRANSPORT_READINESS_INVALID;
|
||||
/// Log payload sent by Backfill Desk frontend scripts.
|
||||
pub(crate) use self::frontend_logging::FrontendLogPayloadDto;
|
||||
/// Emits one validated frontend event through the KSP Logging facade.
|
||||
@@ -92,6 +99,10 @@ pub(crate) use self::logging_runtime::launch_identity;
|
||||
pub(crate) use self::splash::SplashOrderDto;
|
||||
/// Runtime timings used by the common desk splash lifecycle.
|
||||
pub(crate) use self::splash::SplashSettings;
|
||||
/// Safe and executable Transport runtime retained by application state.
|
||||
pub(crate) use self::transport_runtime::TransportRuntime;
|
||||
/// Initializes the composite-selected HTTP Transport runtime.
|
||||
pub(crate) use self::transport_runtime::initialize_transport;
|
||||
/// Resolves the required main window or returns a typed error.
|
||||
pub(crate) use self::tw_main::require_main_window;
|
||||
/// Shows and focuses the main Backfill Desk window.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-backfill-desk/src/tauri.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Tauri runtime assembly for the KSP Backfill desktop application.
|
||||
|
||||
@@ -72,7 +72,7 @@ fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<taur
|
||||
|
||||
#[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch.
|
||||
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
return builder.invoke_handler(tauri::generate_handler![emit_frontend_log, get_runtime_status, splash_frontend_ready]);
|
||||
return builder.invoke_handler(tauri::generate_handler![backfill_options, emit_frontend_log, get_runtime_status, splash_frontend_ready]);
|
||||
}
|
||||
|
||||
fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
@@ -102,6 +102,15 @@ fn project_command_error(command: &'static str, domain: &'static str, error: &ks
|
||||
return crate::CommandErrorDto::from_error(error);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn backfill_options(state: tauri::State<'_, crate::AppState>) -> std::result::Result<crate::BackfillDeskOptionsDto, crate::CommandErrorDto> {
|
||||
let result = state.backfill_options();
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("backfill_options", crate::TRACING_DOMAIN_TRANSPORT, &error)),
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> {
|
||||
let result = crate::emit_frontend_log_event(payload);
|
||||
|
||||
148
crates/ksp-app-backfill-desk/src/transport_runtime.rs
Normal file
148
crates/ksp-app-backfill-desk/src/transport_runtime.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
// file: crates/ksp-app-backfill-desk/src/transport_runtime.rs
|
||||
// version: 1
|
||||
|
||||
//! Composite-selected HTTP Transport readiness owned by Backfill Desk.
|
||||
|
||||
/// Safe and executable Transport runtime retained by the application state.
|
||||
pub(crate) struct TransportRuntime {
|
||||
pool: ksp_onchain_transport_lib::HttpTransportPool,
|
||||
profile_id: String,
|
||||
}
|
||||
|
||||
impl TransportRuntime {
|
||||
/// Returns the composite-selected Transport profile identifier.
|
||||
#[must_use]
|
||||
pub(crate) fn profile_id(&self) -> &str {
|
||||
return self.profile_id.as_str();
|
||||
}
|
||||
|
||||
/// Builds the current safe Transport-only projection for the Backfill options surface.
|
||||
pub(crate) fn options(&self) -> ksp_core_lib::Result<crate::BackfillDeskOptionsDto> {
|
||||
let snapshot = self.pool.snapshot();
|
||||
let configured_networks = configured_networks(&snapshot);
|
||||
let compatible_roles = compatible_backfill_roles(&self.pool, &snapshot);
|
||||
let compatible_roles = match compatible_roles {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let transport_ready = configured_networks.len() == 1 && !compatible_roles.is_empty() && snapshot.available_endpoint_count() > 0;
|
||||
return std::result::Result::Ok(crate::BackfillDeskOptionsDto {
|
||||
compatible_roles,
|
||||
configured_networks,
|
||||
transport_diagnostic: std::option::Option::None,
|
||||
transport_ready,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the composite-selected Transport profile and builds the executable HTTP pool.
|
||||
pub(crate) fn initialize_transport(management: &ksp_config_lib::ConfigManagement) -> ksp_core_lib::Result<TransportRuntime> {
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let composite = crate::load_backfill_desk_composite(management);
|
||||
let composite = match composite {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let profile = crate::required_composite_component_profile(&composite, crate::COMPOSITE_COMPONENT_ID_TRANSPORT, ksp_config_lib::FILE_ID_STD_TRANSPORT);
|
||||
let profile = match profile {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let resolved = management.engine().resolve_transport_config_profile(&profile, &environment);
|
||||
let resolved = match resolved {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let profile_id = resolved.profile_id().to_owned();
|
||||
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into_settings());
|
||||
let pool = match pool {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let runtime = TransportRuntime { pool, profile_id };
|
||||
let options = runtime.options();
|
||||
let options = match options {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_TRANSPORT,
|
||||
transport_profile = runtime.profile_id(),
|
||||
network_count = options.configured_networks.len(),
|
||||
compatible_role_count = options.compatible_roles.len(),
|
||||
transport_ready = options.transport_ready,
|
||||
"initialized Backfill Desk HTTP Transport readiness from composite-managed configuration"
|
||||
);
|
||||
return std::result::Result::Ok(runtime);
|
||||
}
|
||||
|
||||
fn compatible_backfill_roles(
|
||||
pool: &ksp_onchain_transport_lib::HttpTransportPool,
|
||||
snapshot: &ksp_onchain_transport_lib::HttpTransportPoolSnapshot,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<String>> {
|
||||
let signatures = required_http_rpc_method("getSignaturesForAddress");
|
||||
let signatures = match signatures {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let transaction = required_http_rpc_method("getTransaction");
|
||||
let transaction = match transaction {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut candidates = std::collections::BTreeSet::new();
|
||||
for endpoint in snapshot.endpoints() {
|
||||
if !endpoint.enabled() {
|
||||
continue;
|
||||
}
|
||||
for role in endpoint.roles() {
|
||||
if role.enabled() {
|
||||
candidates.insert(role.role().to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut compatible = std::vec::Vec::new();
|
||||
for candidate in candidates {
|
||||
let role = ksp_onchain_transport_lib::HttpRoleName::new(candidate.clone());
|
||||
if pool.select_for_method(&role, signatures).is_ok() && pool.select_for_method(&role, transaction).is_ok() {
|
||||
compatible.push(candidate);
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(compatible);
|
||||
}
|
||||
|
||||
fn configured_networks(snapshot: &ksp_onchain_transport_lib::HttpTransportPoolSnapshot) -> std::vec::Vec<String> {
|
||||
let mut values = snapshot
|
||||
.endpoints()
|
||||
.iter()
|
||||
.filter_map(|endpoint| {
|
||||
if endpoint.enabled() {
|
||||
return std::option::Option::Some(endpoint.cluster().to_owned());
|
||||
}
|
||||
return std::option::Option::None;
|
||||
})
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
values.sort();
|
||||
values.dedup();
|
||||
return values;
|
||||
}
|
||||
|
||||
fn required_http_rpc_method(method: &'static str) -> ksp_core_lib::Result<&'static ksp_onchain_transport_lib::HttpRpcMethodDescriptor> {
|
||||
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method);
|
||||
return match descriptor {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TRANSPORT_READINESS_INVALID, "Backfill Desk Transport registry is missing a required HTTP RPC method")
|
||||
.with_context("rpc_method", method),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/transport_runtime.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user