v0.2.12-pre.006
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 292
|
# version: 293
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.12-pre.5.fix.1"
|
version = "0.2.12-pre.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-app-solprices-desk/frontend/main.html -->
|
<!-- file: crates/ksp-app-solprices-desk/frontend/main.html -->
|
||||||
<!-- version: 6 -->
|
<!-- version: 7 -->
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
|
|
||||||
@@ -44,10 +44,19 @@
|
|||||||
<div class="card app-shell-card mx-auto shadow-sm border-0">
|
<div class="card app-shell-card mx-auto shadow-sm border-0">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<section data-view-panel="prices">
|
<section data-view-panel="prices">
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-1">Prices</h1>
|
<h1 class="h3 mb-1">Prices</h1>
|
||||||
<p class="text-body-secondary mb-0">Inventaire provider-neutral du registry Off-chain. Chaque ligne peut être rafraîchie explicitement sans polling ni fallback automatique.</p>
|
<p class="text-body-secondary mb-0">Inventaire provider-neutral du registry Off-chain. Rafraîchissement individuel, sélectionné ou global, sans polling ni fallback automatique.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span id="selectedProviderCount" class="small text-body-secondary">0 selected</span>
|
||||||
|
<button id="refreshSelectedButton" class="btn btn-sm btn-outline-primary" type="button" disabled>
|
||||||
|
<i class="fa-solid fa-list-check me-1" aria-hidden="true"></i>Refresh selected
|
||||||
|
</button>
|
||||||
|
<button id="refreshAllButton" class="btn btn-sm btn-primary" type="button" disabled>
|
||||||
|
<i class="fa-solid fa-arrows-rotate me-1" aria-hidden="true"></i>Refresh all
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="pricesLoading" class="app-placeholder">
|
<div id="pricesLoading" class="app-placeholder">
|
||||||
@@ -69,6 +78,7 @@
|
|||||||
<table id="marketPriceTable" class="table table-hover align-middle mb-0">
|
<table id="marketPriceTable" class="table table-hover align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th scope="col"><span class="visually-hidden">Select</span></th>
|
||||||
<th scope="col">Provider</th>
|
<th scope="col">Provider</th>
|
||||||
<th scope="col">Pair</th>
|
<th scope="col">Pair</th>
|
||||||
<th scope="col">Semantics</th>
|
<th scope="col">Semantics</th>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-solprices-desk/frontend/ts/main.ts
|
// file: crates/ksp-app-solprices-desk/frontend/ts/main.ts
|
||||||
// version: 5
|
// version: 6
|
||||||
|
|
||||||
import "bootstrap";
|
import "bootstrap";
|
||||||
import ResizeObserver from "resize-observer-polyfill";
|
import ResizeObserver from "resize-observer-polyfill";
|
||||||
@@ -7,6 +7,8 @@ import "simplebar";
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import type { MarketPriceRuntimeStatusDto } from "./bindings/ksp_app_solprices_desk/dto_common/MarketPriceRuntimeStatusDto.ts";
|
import type { MarketPriceRuntimeStatusDto } from "./bindings/ksp_app_solprices_desk/dto_common/MarketPriceRuntimeStatusDto.ts";
|
||||||
import type { MarketPriceProviderRowDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceProviderRowDto.ts";
|
import type { MarketPriceProviderRowDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceProviderRowDto.ts";
|
||||||
|
import type { MarketPriceRefreshManyRequestDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshManyRequestDto.ts";
|
||||||
|
import type { MarketPriceRefreshResultDto } from "./bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshResultDto.ts";
|
||||||
import { frontendDebug, frontendInfo, frontendTrace, frontendWarn, installFrontendConsoleBridge } from "./frontend_log";
|
import { frontendDebug, frontendInfo, frontendTrace, frontendWarn, installFrontendConsoleBridge } from "./frontend_log";
|
||||||
import { invokeKsp } from "./invoke";
|
import { invokeKsp } from "./invoke";
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ const viewTitles: Record<ViewId, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let currentMarketPriceRows: MarketPriceProviderRowDto[] = [];
|
let currentMarketPriceRows: MarketPriceProviderRowDto[] = [];
|
||||||
|
const selectedProviderIds = new Set<string>();
|
||||||
|
|
||||||
function isViewId(value: string | undefined): value is ViewId {
|
function isViewId(value: string | undefined): value is ViewId {
|
||||||
return value === "prices" || value === "diagnostics";
|
return value === "prices" || value === "diagnostics";
|
||||||
@@ -78,13 +81,66 @@ function showPricesDiagnostic(message: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectedProviderIdsInRowOrder(): string[] {
|
||||||
|
return currentMarketPriceRows.filter(provider => selectedProviderIds.has(provider.providerId)).map(provider => provider.providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function synchronizeSelectedProviders(): void {
|
||||||
|
const currentIds = new Set(currentMarketPriceRows.map(provider => provider.providerId));
|
||||||
|
for (const providerId of selectedProviderIds) {
|
||||||
|
if (!currentIds.has(providerId)) {
|
||||||
|
selectedProviderIds.delete(providerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function synchronizeBatchControls(): void {
|
||||||
|
const selectedCount = selectedProviderIdsInRowOrder().length;
|
||||||
|
const anyLoading = currentMarketPriceRows.some(provider => provider.loading);
|
||||||
|
const selectedButton = document.querySelector<HTMLButtonElement>("#refreshSelectedButton");
|
||||||
|
const allButton = document.querySelector<HTMLButtonElement>("#refreshAllButton");
|
||||||
|
const selectedCountLabel = document.querySelector<HTMLElement>("#selectedProviderCount");
|
||||||
|
if (selectedButton) {
|
||||||
|
selectedButton.disabled = selectedCount === 0 || anyLoading;
|
||||||
|
}
|
||||||
|
if (allButton) {
|
||||||
|
allButton.disabled = currentMarketPriceRows.length === 0 || anyLoading;
|
||||||
|
}
|
||||||
|
if (selectedCountLabel) {
|
||||||
|
selectedCountLabel.textContent = `${selectedCount} selected`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markProvidersLoading(providerIds: string[]): void {
|
||||||
|
const loadingIds = new Set(providerIds);
|
||||||
|
currentMarketPriceRows = currentMarketPriceRows.map(provider => {
|
||||||
|
return loadingIds.has(provider.providerId) ? { ...provider, loading: true } : provider;
|
||||||
|
});
|
||||||
|
renderMarketPriceRows(currentMarketPriceRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRefreshResult(result: MarketPriceRefreshResultDto): void {
|
||||||
|
const refreshedRows = new Map<string, MarketPriceProviderRowDto>();
|
||||||
|
for (const provider of result.rows) {
|
||||||
|
refreshedRows.set(provider.providerId, provider);
|
||||||
|
}
|
||||||
|
currentMarketPriceRows = currentMarketPriceRows.map(provider => refreshedRows.get(provider.providerId) ?? provider);
|
||||||
|
renderMarketPriceRows(currentMarketPriceRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverMarketPriceRows(message: string): Promise<void> {
|
||||||
|
showPricesDiagnostic(message);
|
||||||
|
try {
|
||||||
|
await loadMarketPrices();
|
||||||
|
} catch {
|
||||||
|
frontendWarn("main", "SOL Prices Desk market-price refresh recovery reload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshMarketPrice(providerId: string): Promise<void> {
|
async function refreshMarketPrice(providerId: string): Promise<void> {
|
||||||
frontendDebug("main", "SOL Prices Desk market-price refresh control clicked", { providerId });
|
frontendDebug("main", "SOL Prices Desk market-price refresh control clicked", { providerId });
|
||||||
clearPricesDiagnostic();
|
clearPricesDiagnostic();
|
||||||
currentMarketPriceRows = currentMarketPriceRows.map(provider => {
|
markProvidersLoading([providerId]);
|
||||||
return provider.providerId === providerId ? { ...provider, loading: true } : provider;
|
|
||||||
});
|
|
||||||
renderMarketPriceRows(currentMarketPriceRows);
|
|
||||||
try {
|
try {
|
||||||
const refreshed = await invokeKsp<MarketPriceProviderRowDto>("main", "refresh_market_price", { providerId });
|
const refreshed = await invokeKsp<MarketPriceProviderRowDto>("main", "refresh_market_price", { providerId });
|
||||||
currentMarketPriceRows = currentMarketPriceRows.map(provider => {
|
currentMarketPriceRows = currentMarketPriceRows.map(provider => {
|
||||||
@@ -94,15 +150,76 @@ async function refreshMarketPrice(providerId: string): Promise<void> {
|
|||||||
frontendTrace("main", "SOL Prices Desk market-price refresh row applied", { providerId });
|
frontendTrace("main", "SOL Prices Desk market-price refresh row applied", { providerId });
|
||||||
} catch {
|
} catch {
|
||||||
frontendWarn("main", "SOL Prices Desk market-price refresh failed", { providerId });
|
frontendWarn("main", "SOL Prices Desk market-price refresh failed", { providerId });
|
||||||
showPricesDiagnostic("Le provider sélectionné n'a pas pu être rafraîchi. L'état précédent est conservé lorsqu'il reste disponible.");
|
await recoverMarketPriceRows("Le provider sélectionné n'a pas pu être rafraîchi. L'état précédent est conservé lorsqu'il reste disponible.");
|
||||||
try {
|
|
||||||
await loadMarketPrices();
|
|
||||||
} catch {
|
|
||||||
frontendWarn("main", "SOL Prices Desk market-price refresh recovery reload failed", { providerId });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshSelectedMarketPrices(): Promise<void> {
|
||||||
|
const providerIds = selectedProviderIdsInRowOrder();
|
||||||
|
frontendDebug("main", "SOL Prices Desk selected market-price refresh control clicked", { selectedCount: providerIds.length });
|
||||||
|
if (providerIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPricesDiagnostic();
|
||||||
|
markProvidersLoading(providerIds);
|
||||||
|
const request: MarketPriceRefreshManyRequestDto = { providerIds };
|
||||||
|
try {
|
||||||
|
const result = await invokeKsp<MarketPriceRefreshResultDto>("main", "refresh_market_prices", { request });
|
||||||
|
applyRefreshResult(result);
|
||||||
|
frontendTrace("main", "SOL Prices Desk selected market-price refresh result applied", {
|
||||||
|
requestedCount: result.requestedCount,
|
||||||
|
refreshedCount: result.refreshedCount,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
frontendWarn("main", "SOL Prices Desk selected market-price refresh failed", { selectedCount: providerIds.length });
|
||||||
|
await recoverMarketPriceRows("La sélection n'a pas pu être rafraîchie. Les observations précédentes restent visibles avec leur état réel.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAllMarketPrices(): Promise<void> {
|
||||||
|
frontendDebug("main", "SOL Prices Desk global market-price refresh control clicked", { providerCount: currentMarketPriceRows.length });
|
||||||
|
if (currentMarketPriceRows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPricesDiagnostic();
|
||||||
|
markProvidersLoading(currentMarketPriceRows.map(provider => provider.providerId));
|
||||||
|
try {
|
||||||
|
const result = await invokeKsp<MarketPriceRefreshResultDto>("main", "refresh_all_market_prices");
|
||||||
|
applyRefreshResult(result);
|
||||||
|
frontendTrace("main", "SOL Prices Desk global market-price refresh result applied", {
|
||||||
|
requestedCount: result.requestedCount,
|
||||||
|
refreshedCount: result.refreshedCount,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
frontendWarn("main", "SOL Prices Desk global market-price refresh failed", { providerCount: currentMarketPriceRows.length });
|
||||||
|
await recoverMarketPriceRows("Le refresh global n'a pas pu être terminé. Les observations précédentes restent visibles avec leur état réel.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSelectionCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
const checkbox = document.createElement("input");
|
||||||
|
checkbox.type = "checkbox";
|
||||||
|
checkbox.className = "form-check-input";
|
||||||
|
checkbox.checked = selectedProviderIds.has(provider.providerId);
|
||||||
|
checkbox.disabled = provider.loading;
|
||||||
|
checkbox.setAttribute("aria-label", `Select ${provider.displayName}`);
|
||||||
|
checkbox.addEventListener("change", () => {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
selectedProviderIds.add(provider.providerId);
|
||||||
|
} else {
|
||||||
|
selectedProviderIds.delete(provider.providerId);
|
||||||
|
}
|
||||||
|
frontendDebug("main", "SOL Prices Desk market-price selection control changed", {
|
||||||
|
providerId: provider.providerId,
|
||||||
|
selected: checkbox.checked,
|
||||||
|
});
|
||||||
|
synchronizeBatchControls();
|
||||||
|
});
|
||||||
|
cell.append(checkbox);
|
||||||
|
row.append(cell);
|
||||||
|
}
|
||||||
|
|
||||||
function appendRefreshCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
function appendRefreshCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||||
const cell = document.createElement("td");
|
const cell = document.createElement("td");
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
@@ -132,12 +249,15 @@ function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void {
|
|||||||
frontendWarn("main", "SOL Prices Desk market-price table elements are missing");
|
frontendWarn("main", "SOL Prices Desk market-price table elements are missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
currentMarketPriceRows = rows;
|
||||||
|
synchronizeSelectedProviders();
|
||||||
tableBody.replaceChildren();
|
tableBody.replaceChildren();
|
||||||
empty.hidden = rows.length !== 0;
|
empty.hidden = rows.length !== 0;
|
||||||
tableContainer.hidden = rows.length === 0;
|
tableContainer.hidden = rows.length === 0;
|
||||||
for (const provider of rows) {
|
for (const provider of rows) {
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
row.dataset.providerId = provider.providerId;
|
row.dataset.providerId = provider.providerId;
|
||||||
|
appendSelectionCell(row, provider);
|
||||||
appendMarketPriceCell(row, provider.displayName);
|
appendMarketPriceCell(row, provider.displayName);
|
||||||
appendMarketPriceCell(row, provider.pair);
|
appendMarketPriceCell(row, provider.pair);
|
||||||
appendMarketPriceCell(row, provider.semantics);
|
appendMarketPriceCell(row, provider.semantics);
|
||||||
@@ -150,9 +270,23 @@ function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void {
|
|||||||
appendRefreshCell(row, provider);
|
appendRefreshCell(row, provider);
|
||||||
tableBody.append(row);
|
tableBody.append(row);
|
||||||
}
|
}
|
||||||
|
synchronizeBatchControls();
|
||||||
frontendTrace("main", "SOL Prices Desk market-price registry rows rendered", { rowCount: rows.length });
|
frontendTrace("main", "SOL Prices Desk market-price registry rows rendered", { rowCount: rows.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindMarketPriceControls(): void {
|
||||||
|
const selectedButton = document.querySelector<HTMLButtonElement>("#refreshSelectedButton");
|
||||||
|
const allButton = document.querySelector<HTMLButtonElement>("#refreshAllButton");
|
||||||
|
selectedButton?.addEventListener("click", () => {
|
||||||
|
void refreshSelectedMarketPrices();
|
||||||
|
});
|
||||||
|
allButton?.addEventListener("click", () => {
|
||||||
|
void refreshAllMarketPrices();
|
||||||
|
});
|
||||||
|
synchronizeBatchControls();
|
||||||
|
frontendTrace("main", "SOL Prices Desk market-price batch controls installed");
|
||||||
|
}
|
||||||
|
|
||||||
function renderRuntimeStatus(status: MarketPriceRuntimeStatusDto): void {
|
function renderRuntimeStatus(status: MarketPriceRuntimeStatusDto): void {
|
||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
runtimeCompositeProfile: status.activeCompositeProfile,
|
runtimeCompositeProfile: status.activeCompositeProfile,
|
||||||
@@ -203,6 +337,7 @@ async function initializeMain(): Promise<void> {
|
|||||||
const windowLabel = getCurrentWindow().label;
|
const windowLabel = getCurrentWindow().label;
|
||||||
frontendInfo("main", "SOL Prices Desk main frontend loaded", { windowLabel });
|
frontendInfo("main", "SOL Prices Desk main frontend loaded", { windowLabel });
|
||||||
bindNavigation();
|
bindNavigation();
|
||||||
|
bindMarketPriceControls();
|
||||||
activateView("prices", "startup");
|
activateView("prices", "startup");
|
||||||
try {
|
try {
|
||||||
await loadRuntimeStatus();
|
await loadRuntimeStatus();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ksp-app-solprices-desk",
|
"name": "ksp-app-solprices-desk",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.12-pre.5.fix.1",
|
"version": "0.2.12-pre.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-solprices-desk/src/app_state.rs
|
// file: crates/ksp-app-solprices-desk/src/app_state.rs
|
||||||
// version: 4
|
// version: 5
|
||||||
|
|
||||||
//! Shared backend state owned by the SOL Prices Desk Tauri application.
|
//! Shared backend state owned by the SOL Prices Desk Tauri application.
|
||||||
|
|
||||||
@@ -73,6 +73,19 @@ impl crate::AppState {
|
|||||||
return self.market_price_runtime.refresh_one(provider_id).await;
|
return self.market_price_runtime.refresh_one(provider_id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refreshes a selected provider set in deterministic request order and returns only safe updated rows/counts.
|
||||||
|
pub(crate) async fn refresh_market_prices(
|
||||||
|
&self,
|
||||||
|
request: crate::MarketPriceRefreshManyRequestDto,
|
||||||
|
) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
|
||||||
|
return self.market_price_runtime.refresh_many(request.into_provider_ids()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes every configured provider in the deterministic order owned by Off-chain Transport.
|
||||||
|
pub(crate) async fn refresh_all_market_prices(&self) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
|
||||||
|
return self.market_price_runtime.refresh_all().await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the safe runtime status exposed by the SOL Prices Desk shell.
|
/// Builds the safe runtime status exposed by the SOL Prices Desk shell.
|
||||||
pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result<crate::MarketPriceRuntimeStatusDto> {
|
pub(crate) fn runtime_status(&self) -> ksp_core_lib::Result<crate::MarketPriceRuntimeStatusDto> {
|
||||||
let document_count = self.config_management.engine().registry().descriptors().count();
|
let document_count = self.config_management.engine().registry().descriptors().count();
|
||||||
@@ -116,7 +129,7 @@ impl crate::AppState {
|
|||||||
fallback_logging_active: runtime.fallback_active,
|
fallback_logging_active: runtime.fallback_active,
|
||||||
provider_count,
|
provider_count,
|
||||||
ready_provider_count,
|
ready_provider_count,
|
||||||
shell_phase: "pre.005-single-refresh".to_owned(),
|
shell_phase: "pre.006-multi-refresh".to_owned(),
|
||||||
startup_diagnostic: runtime.startup_diagnostic.clone(),
|
startup_diagnostic: runtime.startup_diagnostic.clone(),
|
||||||
unavailable_provider_count,
|
unavailable_provider_count,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-solprices-desk/src/errors.rs
|
// file: crates/ksp-app-solprices-desk/src/errors.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
//! Application-local error codes for SOL Prices Desk composition and desktop runtime surfaces.
|
//! Application-local error codes for SOL Prices Desk composition and desktop runtime surfaces.
|
||||||
|
|
||||||
@@ -16,6 +16,12 @@ pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode
|
|||||||
ksp_core_lib::ErrorCode::new("solprices_desk", "frontend_log_target_invalid");
|
ksp_core_lib::ErrorCode::new("solprices_desk", "frontend_log_target_invalid");
|
||||||
/// SOL Prices Desk could not install the managed Logging runtime or its safe fallback.
|
/// SOL Prices 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("solprices_desk", "logging_bootstrap_failed");
|
pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "logging_bootstrap_failed");
|
||||||
|
/// One requested market-price provider already has an explicit refresh in flight.
|
||||||
|
pub(crate) const ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("solprices_desk", "market_price_refresh_conflict");
|
||||||
|
/// A selected market-price refresh contains duplicate or unknown provider identifiers.
|
||||||
|
pub(crate) const ERROR_CODE_MARKET_PRICE_SELECTION_INVALID: ksp_core_lib::ErrorCode =
|
||||||
|
ksp_core_lib::ErrorCode::new("solprices_desk", "market_price_selection_invalid");
|
||||||
/// Splash readiness was invoked from a window other than the splash window.
|
/// 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("solprices_desk", "splash_origin_invalid");
|
pub(crate) const ERROR_CODE_SPLASH_ORIGIN_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("solprices_desk", "splash_origin_invalid");
|
||||||
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
|
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-solprices-desk/src/lib.rs
|
// file: crates/ksp-app-solprices-desk/src/lib.rs
|
||||||
// version: 3
|
// version: 4
|
||||||
|
|
||||||
//! Tauri desktop application scaffold for provider-neutral SOL/USD price visualization.
|
//! Tauri desktop application scaffold for provider-neutral SOL/USD price visualization.
|
||||||
|
|
||||||
@@ -74,6 +74,10 @@ pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID;
|
|||||||
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID;
|
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID;
|
||||||
/// SOL Prices Desk could not install the managed Logging runtime or its safe fallback.
|
/// SOL Prices Desk could not install the managed Logging runtime or its safe fallback.
|
||||||
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED;
|
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED;
|
||||||
|
/// One requested market-price provider already has an explicit refresh in flight.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT;
|
||||||
|
/// A selected market-price refresh contains duplicate or unknown provider identifiers.
|
||||||
|
pub(crate) use self::errors::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID;
|
||||||
/// Splash readiness was invoked from a window other than the splash window.
|
/// Splash readiness was invoked from a window other than the splash window.
|
||||||
pub(crate) use self::errors::ERROR_CODE_SPLASH_ORIGIN_INVALID;
|
pub(crate) use self::errors::ERROR_CODE_SPLASH_ORIGIN_INVALID;
|
||||||
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
|
/// A KSP desk splash environment duration is malformed or exceeds its safety bound.
|
||||||
@@ -92,6 +96,10 @@ pub(crate) use self::frontend_logging::emit_frontend_log_event;
|
|||||||
pub(crate) use self::logging_runtime::launch_identity;
|
pub(crate) use self::logging_runtime::launch_identity;
|
||||||
/// Safe provider-neutral market-price row exposed to the frontend.
|
/// Safe provider-neutral market-price row exposed to the frontend.
|
||||||
pub(crate) use self::market_price_runtime::MarketPriceProviderRowDto;
|
pub(crate) use self::market_price_runtime::MarketPriceProviderRowDto;
|
||||||
|
/// Bounded provider-neutral request used by the selected-provider refresh command.
|
||||||
|
pub(crate) use self::market_price_runtime::MarketPriceRefreshManyRequestDto;
|
||||||
|
/// Safe result returned by selected/global market-price refresh commands.
|
||||||
|
pub(crate) use self::market_price_runtime::MarketPriceRefreshResultDto;
|
||||||
/// Backend application runtime owning the Config-selected service and in-memory market-price presentation state.
|
/// Backend application runtime owning the Config-selected service and in-memory market-price presentation state.
|
||||||
pub(crate) use self::market_price_runtime::MarketPriceRuntime;
|
pub(crate) use self::market_price_runtime::MarketPriceRuntime;
|
||||||
/// Resolved composite and Off-chain Transport configuration retained by SOL Prices Desk.
|
/// Resolved composite and Off-chain Transport configuration retained by SOL Prices Desk.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-solprices-desk/src/market_price_runtime.rs
|
// file: crates/ksp-app-solprices-desk/src/market_price_runtime.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
//! Provider-neutral market-price presentation runtime owned by SOL Prices Desk.
|
//! Provider-neutral market-price presentation runtime owned by SOL Prices Desk.
|
||||||
|
|
||||||
@@ -36,6 +36,35 @@ pub(crate) struct MarketPriceProviderRowDto {
|
|||||||
pub(crate) semantics: String,
|
pub(crate) semantics: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounded provider-neutral request used by the selected-provider refresh command.
|
||||||
|
#[derive(Debug, serde::Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshManyRequestDto.ts")]
|
||||||
|
pub(crate) struct MarketPriceRefreshManyRequestDto {
|
||||||
|
/// Opaque provider identifiers requested by the frontend in deterministic row order.
|
||||||
|
pub(crate) provider_ids: std::vec::Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::MarketPriceRefreshManyRequestDto {
|
||||||
|
/// Consumes the IPC DTO and returns the opaque provider identifiers for authoritative backend validation.
|
||||||
|
pub(crate) fn into_provider_ids(self) -> std::vec::Vec<String> {
|
||||||
|
return self.provider_ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe result of one selected/global refresh operation.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_solprices_desk/market_price_runtime/MarketPriceRefreshResultDto.ts")]
|
||||||
|
pub(crate) struct MarketPriceRefreshResultDto {
|
||||||
|
/// Number of provider refreshes that produced a new normalized observation.
|
||||||
|
pub(crate) refreshed_count: u32,
|
||||||
|
/// Number of providers included in the validated operation.
|
||||||
|
pub(crate) requested_count: u32,
|
||||||
|
/// Updated provider-neutral rows in the exact dispatch/outcome order.
|
||||||
|
pub(crate) rows: std::vec::Vec<MarketPriceProviderRowDto>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Backend application runtime that owns the Config-selected Off-chain service and current in-memory presentation state.
|
/// Backend application runtime that owns the Config-selected Off-chain service and current in-memory presentation state.
|
||||||
pub(crate) struct MarketPriceRuntime {
|
pub(crate) struct MarketPriceRuntime {
|
||||||
presentation: std::sync::Mutex<MarketPricePresentationState>,
|
presentation: std::sync::Mutex<MarketPricePresentationState>,
|
||||||
@@ -128,7 +157,8 @@ impl crate::MarketPriceRuntime {
|
|||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
};
|
};
|
||||||
let loading = self.set_loading(&provider_id, true);
|
let provider_ids = [provider_id.clone()];
|
||||||
|
let loading = self.begin_refresh(provider_ids.as_slice());
|
||||||
if let std::result::Result::Err(error) = loading {
|
if let std::result::Result::Err(error) = loading {
|
||||||
return std::result::Result::Err(error);
|
return std::result::Result::Err(error);
|
||||||
}
|
}
|
||||||
@@ -142,7 +172,7 @@ impl crate::MarketPriceRuntime {
|
|||||||
let outcome = match outcome {
|
let outcome = match outcome {
|
||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
std::result::Result::Err(error) => {
|
std::result::Result::Err(error) => {
|
||||||
let cleared = self.set_loading(&provider_id, false);
|
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||||
if let std::result::Result::Err(clear_error) = cleared {
|
if let std::result::Result::Err(clear_error) = cleared {
|
||||||
return std::result::Result::Err(clear_error);
|
return std::result::Result::Err(clear_error);
|
||||||
}
|
}
|
||||||
@@ -152,7 +182,13 @@ impl crate::MarketPriceRuntime {
|
|||||||
let row = self.apply_outcome(&outcome);
|
let row = self.apply_outcome(&outcome);
|
||||||
let row = match row {
|
let row = match row {
|
||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
std::result::Result::Err(error) => {
|
||||||
|
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||||
|
if let std::result::Result::Err(clear_error) = cleared {
|
||||||
|
return std::result::Result::Err(clear_error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
ksp_logging_lib::debug!(
|
ksp_logging_lib::debug!(
|
||||||
target: crate::TRACING_TARGET,
|
target: crate::TRACING_TARGET,
|
||||||
@@ -165,6 +201,101 @@ impl crate::MarketPriceRuntime {
|
|||||||
return std::result::Result::Ok(row);
|
return std::result::Result::Ok(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refreshes a frontend-selected provider set in deterministic request order through the generic Off-chain service.
|
||||||
|
pub(crate) async fn refresh_many(&self, provider_ids: std::vec::Vec<String>) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
|
||||||
|
let provider_ids = parse_provider_ids(provider_ids);
|
||||||
|
let provider_ids = match provider_ids {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let loading = self.begin_refresh(provider_ids.as_slice());
|
||||||
|
if let std::result::Result::Err(error) = loading {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||||
|
provider_count = provider_ids.len(),
|
||||||
|
"started SOL Prices Desk selected-provider market-price refresh"
|
||||||
|
);
|
||||||
|
let outcomes = self.startup.resolved().service().refresh_many(provider_ids.as_slice()).await;
|
||||||
|
let outcomes = match outcomes {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||||
|
if let std::result::Result::Err(clear_error) = cleared {
|
||||||
|
return std::result::Result::Err(clear_error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let result = self.apply_outcomes(outcomes.as_slice());
|
||||||
|
let result = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||||
|
if let std::result::Result::Err(clear_error) = cleared {
|
||||||
|
return std::result::Result::Err(clear_error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||||
|
provider_count = result.requested_count,
|
||||||
|
refreshed_count = result.refreshed_count,
|
||||||
|
"completed SOL Prices Desk selected-provider market-price refresh"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes every configured provider through the generic Off-chain service in its stable provider order.
|
||||||
|
pub(crate) async fn refresh_all(&self) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
|
||||||
|
let registry = self.startup.resolved().service().registry();
|
||||||
|
let provider_ids = registry.entries().iter().map(|entry| return entry.descriptor().id().clone()).collect::<std::vec::Vec<_>>();
|
||||||
|
let loading = self.begin_refresh(provider_ids.as_slice());
|
||||||
|
if let std::result::Result::Err(error) = loading {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||||
|
provider_count = provider_ids.len(),
|
||||||
|
"started SOL Prices Desk global market-price refresh"
|
||||||
|
);
|
||||||
|
let outcomes = self.startup.resolved().service().refresh_all().await;
|
||||||
|
let outcomes = match outcomes {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||||
|
if let std::result::Result::Err(clear_error) = cleared {
|
||||||
|
return std::result::Result::Err(clear_error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let result = self.apply_outcomes(outcomes.as_slice());
|
||||||
|
let result = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
let cleared = self.clear_loading(provider_ids.as_slice());
|
||||||
|
if let std::result::Result::Err(clear_error) = cleared {
|
||||||
|
return std::result::Result::Err(clear_error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||||
|
provider_count = result.requested_count,
|
||||||
|
refreshed_count = result.refreshed_count,
|
||||||
|
"completed SOL Prices Desk global market-price refresh"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_outcome(&self, outcome: &ksp_offchain_transport_lib::MarketPriceRefreshOutcome) -> ksp_core_lib::Result<crate::MarketPriceProviderRowDto> {
|
fn apply_outcome(&self, outcome: &ksp_offchain_transport_lib::MarketPriceRefreshOutcome) -> ksp_core_lib::Result<crate::MarketPriceProviderRowDto> {
|
||||||
let registry = self.startup.resolved().service().registry();
|
let registry = self.startup.resolved().service().registry();
|
||||||
let registry_entry = registry.entry(outcome.provider_id());
|
let registry_entry = registry.entry(outcome.provider_id());
|
||||||
@@ -196,13 +327,86 @@ impl crate::MarketPriceRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_loading(&self, provider_id: &ksp_offchain_transport_lib::MarketPriceProviderId, loading: bool) -> ksp_core_lib::Result<()> {
|
fn apply_outcomes(&self, outcomes: &[ksp_offchain_transport_lib::MarketPriceRefreshOutcome]) -> ksp_core_lib::Result<crate::MarketPriceRefreshResultDto> {
|
||||||
|
let registry = self.startup.resolved().service().registry();
|
||||||
let presentation = lock_presentation(&self.presentation);
|
let presentation = lock_presentation(&self.presentation);
|
||||||
let mut presentation = match presentation {
|
let mut presentation = match presentation {
|
||||||
std::result::Result::Ok(value) => value,
|
std::result::Result::Ok(value) => value,
|
||||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
};
|
};
|
||||||
return set_loading_in_state(&mut presentation, provider_id.as_str(), loading);
|
let mut rows = std::vec::Vec::with_capacity(outcomes.len());
|
||||||
|
let mut refreshed_count = 0usize;
|
||||||
|
for outcome in outcomes {
|
||||||
|
let registry_entry = registry.entry(outcome.provider_id());
|
||||||
|
let registry_entry = match registry_entry {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||||
|
"SOL Prices Desk refresh outcome references an unknown registry provider",
|
||||||
|
)
|
||||||
|
.with_context("provider_id", outcome.provider_id().as_str()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let applied = apply_outcome_to_state(&mut presentation, outcome);
|
||||||
|
if let std::result::Result::Err(error) = applied {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if outcome.refreshed() {
|
||||||
|
refreshed_count += 1;
|
||||||
|
}
|
||||||
|
let presentation_entry = presentation.entries.get(outcome.provider_id().as_str());
|
||||||
|
let presentation_entry = match presentation_entry {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_APP_STATE_INVALID,
|
||||||
|
"SOL Prices Desk market-price runtime is missing refreshed provider state",
|
||||||
|
)
|
||||||
|
.with_context("provider_id", outcome.provider_id().as_str()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
rows.push(project_registry_entry(registry_entry, presentation_entry));
|
||||||
|
}
|
||||||
|
let requested_count = count_to_u32(outcomes.len(), "refresh_requested_count");
|
||||||
|
let requested_count = match requested_count {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let refreshed_count = count_to_u32(refreshed_count, "refresh_success_count");
|
||||||
|
let refreshed_count = match refreshed_count {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::MarketPriceRefreshResultDto { refreshed_count, requested_count, rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_refresh(&self, provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId]) -> ksp_core_lib::Result<()> {
|
||||||
|
let presentation = lock_presentation(&self.presentation);
|
||||||
|
let mut presentation = match presentation {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return begin_refresh_in_state(&mut presentation, provider_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_loading(&self, provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId]) -> ksp_core_lib::Result<()> {
|
||||||
|
let presentation = lock_presentation(&self.presentation);
|
||||||
|
let mut presentation = match presentation {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
for provider_id in provider_ids {
|
||||||
|
let cleared = set_loading_in_state(&mut presentation, provider_id.as_str(), false);
|
||||||
|
if let std::result::Result::Err(error) = cleared {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +462,53 @@ fn availability_code(availability: ksp_offchain_transport_lib::MarketPriceProvid
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn begin_refresh_in_state(
|
||||||
|
presentation: &mut MarketPricePresentationState,
|
||||||
|
provider_ids: &[ksp_offchain_transport_lib::MarketPriceProviderId],
|
||||||
|
) -> ksp_core_lib::Result<()> {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
for provider_id in provider_ids {
|
||||||
|
if !seen.insert(provider_id.as_str().to_owned()) {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID,
|
||||||
|
"SOL Prices Desk market-price refresh selection contains a duplicate provider",
|
||||||
|
)
|
||||||
|
.with_context("provider_id", provider_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let entry = presentation.entries.get(provider_id.as_str());
|
||||||
|
let entry = match entry {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_MARKET_PRICE_SELECTION_INVALID,
|
||||||
|
"SOL Prices Desk market-price refresh selection contains an unknown provider",
|
||||||
|
)
|
||||||
|
.with_context("provider_id", provider_id.as_str()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if entry.loading {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(
|
||||||
|
crate::ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT,
|
||||||
|
"SOL Prices Desk market-price provider already has a refresh in flight",
|
||||||
|
)
|
||||||
|
.with_context("provider_id", provider_id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for provider_id in provider_ids {
|
||||||
|
let updated = set_loading_in_state(presentation, provider_id.as_str(), true);
|
||||||
|
if let std::result::Result::Err(error) = updated {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
fn count_to_u32(value: usize, field: &'static str) -> ksp_core_lib::Result<u32> {
|
fn count_to_u32(value: usize, field: &'static str) -> ksp_core_lib::Result<u32> {
|
||||||
let converted = u32::try_from(value);
|
let converted = u32::try_from(value);
|
||||||
return match converted {
|
return match converted {
|
||||||
@@ -282,6 +533,19 @@ fn lock_presentation(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_provider_ids(provider_ids: std::vec::Vec<String>) -> ksp_core_lib::Result<std::vec::Vec<ksp_offchain_transport_lib::MarketPriceProviderId>> {
|
||||||
|
let mut parsed = std::vec::Vec::with_capacity(provider_ids.len());
|
||||||
|
for provider_id in provider_ids {
|
||||||
|
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new(provider_id);
|
||||||
|
let provider_id = match provider_id {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
parsed.push(provider_id);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
fn project_registry_entry(
|
fn project_registry_entry(
|
||||||
entry: &ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry,
|
entry: &ksp_offchain_transport_lib::MarketPriceProviderRegistryEntry,
|
||||||
presentation: &MarketPricePresentationEntry,
|
presentation: &MarketPricePresentationEntry,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-solprices-desk/src/tauri.rs
|
// file: crates/ksp-app-solprices-desk/src/tauri.rs
|
||||||
// version: 4
|
// version: 5
|
||||||
|
|
||||||
//! Tauri runtime assembly for the KSP SOL prices desktop application.
|
//! Tauri runtime assembly for the KSP SOL prices desktop application.
|
||||||
|
|
||||||
@@ -76,7 +76,9 @@ fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tau
|
|||||||
emit_frontend_log,
|
emit_frontend_log,
|
||||||
get_runtime_status,
|
get_runtime_status,
|
||||||
list_market_prices,
|
list_market_prices,
|
||||||
|
refresh_all_market_prices,
|
||||||
refresh_market_price,
|
refresh_market_price,
|
||||||
|
refresh_market_prices,
|
||||||
splash_frontend_ready,
|
splash_frontend_ready,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -128,6 +130,17 @@ fn list_market_prices(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn refresh_all_market_prices(
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
) -> std::result::Result<crate::MarketPriceRefreshResultDto, crate::CommandErrorDto> {
|
||||||
|
let result = state.refresh_all_market_prices().await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn refresh_market_price(
|
async fn refresh_market_price(
|
||||||
provider_id: String,
|
provider_id: String,
|
||||||
@@ -140,6 +153,18 @@ async fn refresh_market_price(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn refresh_market_prices(
|
||||||
|
request: crate::MarketPriceRefreshManyRequestDto,
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
) -> std::result::Result<crate::MarketPriceRefreshResultDto, crate::CommandErrorDto> {
|
||||||
|
let result = state.refresh_market_prices(request).await;
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn splash_frontend_ready(
|
async fn splash_frontend_ready(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "KSP SOL Prices Desk",
|
"productName": "KSP SOL Prices Desk",
|
||||||
"version": "0.2.12-pre.5.fix.1",
|
"version": "0.2.12-pre.6",
|
||||||
"identifier": "com.sasedev.ksp-app-solprices-desk",
|
"identifier": "com.sasedev.ksp-app-solprices-desk",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": {
|
"beforeDevCommand": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs
|
// file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs
|
||||||
// version: 5
|
// version: 6
|
||||||
|
|
||||||
//! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`.
|
//! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`.
|
||||||
|
|
||||||
@@ -192,29 +192,21 @@ fn pre_002_fix_001_reuses_the_common_ksp_desktop_template() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pre_005_market_price_runtime_adds_single_refresh_without_many_or_all_controls() {
|
fn pre_005_market_price_runtime_preserves_single_refresh_contract() {
|
||||||
let root = app_root();
|
let root = app_root();
|
||||||
let manifest = read_text(root.join("Cargo.toml").as_path());
|
let manifest = read_text(root.join("Cargo.toml").as_path());
|
||||||
assert!(manifest.contains(r#"ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }"#));
|
assert!(manifest.contains(r#"ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" }"#));
|
||||||
assert!(!manifest.contains("reqwest"));
|
assert!(!manifest.contains("reqwest"));
|
||||||
let lib_rs = read_text(root.join("src/lib.rs").as_path());
|
|
||||||
assert!(lib_rs.contains("mod market_price_runtime;"));
|
|
||||||
assert!(lib_rs.contains("MarketPriceProviderRowDto"));
|
|
||||||
assert!(lib_rs.contains("MarketPriceRuntime"));
|
|
||||||
let runtime_rs = read_text(root.join("src/market_price_runtime.rs").as_path());
|
let runtime_rs = read_text(root.join("src/market_price_runtime.rs").as_path());
|
||||||
assert!(runtime_rs.contains("pub(crate) async fn refresh_one"));
|
assert!(runtime_rs.contains("pub(crate) async fn refresh_one"));
|
||||||
assert!(runtime_rs.contains("service().refresh(&provider_id).await"));
|
assert!(runtime_rs.contains("service().refresh(&provider_id).await"));
|
||||||
assert!(runtime_rs.contains("self.set_loading(&provider_id, true)"));
|
assert!(runtime_rs.contains("self.begin_refresh(provider_ids.as_slice())"));
|
||||||
assert!(runtime_rs.contains("apply_outcome_to_state"));
|
assert!(runtime_rs.contains("apply_outcome_to_state"));
|
||||||
let tauri_rs = read_text(root.join("src/tauri.rs").as_path());
|
let tauri_rs = read_text(root.join("src/tauri.rs").as_path());
|
||||||
assert!(tauri_rs.contains("refresh_market_price"));
|
assert!(tauri_rs.contains("refresh_market_price"));
|
||||||
for forbidden in ["refresh_market_prices", "refresh_all_market_prices"] {
|
|
||||||
assert!(!tauri_rs.contains(forbidden));
|
|
||||||
}
|
|
||||||
let main_html = read_text(root.join("frontend/main.html").as_path());
|
let main_html = read_text(root.join("frontend/main.html").as_path());
|
||||||
assert!(main_html.contains(r#"id="marketPriceTable""#));
|
assert!(main_html.contains(r#"id="marketPriceTable""#));
|
||||||
assert!(main_html.contains(">Action</th>"));
|
assert!(main_html.contains(">Action</th>"));
|
||||||
assert!(main_html.contains("rafraîchie explicitement"));
|
|
||||||
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
|
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
assert!(main_typescript.contains(r#""refresh_market_price""#));
|
assert!(main_typescript.contains(r#""refresh_market_price""#));
|
||||||
assert!(main_typescript.contains("SOL Prices Desk market-price refresh control clicked"));
|
assert!(main_typescript.contains("SOL Prices Desk market-price refresh control clicked"));
|
||||||
@@ -224,3 +216,33 @@ fn pre_005_market_price_runtime_adds_single_refresh_without_many_or_all_controls
|
|||||||
assert!(!main_typescript.contains("parseFloat("));
|
assert!(!main_typescript.contains("parseFloat("));
|
||||||
assert!(!main_typescript.contains("Number(provider.price"));
|
assert!(!main_typescript.contains("Number(provider.price"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_market_price_runtime_adds_selected_and_global_refresh_without_polling() {
|
||||||
|
let root = app_root();
|
||||||
|
let lib_rs = read_text(root.join("src/lib.rs").as_path());
|
||||||
|
assert!(lib_rs.contains("MarketPriceRefreshManyRequestDto"));
|
||||||
|
assert!(lib_rs.contains("MarketPriceRefreshResultDto"));
|
||||||
|
let runtime_rs = read_text(root.join("src/market_price_runtime.rs").as_path());
|
||||||
|
assert!(runtime_rs.contains("pub(crate) async fn refresh_many"));
|
||||||
|
assert!(runtime_rs.contains("service().refresh_many(provider_ids.as_slice()).await"));
|
||||||
|
assert!(runtime_rs.contains("pub(crate) async fn refresh_all"));
|
||||||
|
assert!(runtime_rs.contains("service().refresh_all().await"));
|
||||||
|
assert!(runtime_rs.contains("begin_refresh_in_state"));
|
||||||
|
let tauri_rs = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
assert!(tauri_rs.contains("refresh_market_prices"));
|
||||||
|
assert!(tauri_rs.contains("refresh_all_market_prices"));
|
||||||
|
let main_html = read_text(root.join("frontend/main.html").as_path());
|
||||||
|
assert!(main_html.contains(r#"id="refreshSelectedButton""#));
|
||||||
|
assert!(main_html.contains(r#"id="refreshAllButton""#));
|
||||||
|
assert!(main_html.contains(r#"id="selectedProviderCount""#));
|
||||||
|
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(main_typescript.contains(r#""refresh_market_prices""#));
|
||||||
|
assert!(main_typescript.contains(r#""refresh_all_market_prices""#));
|
||||||
|
assert!(main_typescript.contains("selectedProviderIdsInRowOrder"));
|
||||||
|
assert!(main_typescript.contains("SOL Prices Desk selected market-price refresh control clicked"));
|
||||||
|
assert!(main_typescript.contains("SOL Prices Desk global market-price refresh control clicked"));
|
||||||
|
for forbidden in ["setInterval(", "setTimeout(", "requestAnimationFrame("] {
|
||||||
|
assert!(!main_typescript.contains(forbidden), "pre.006 must not add consumer scheduling: {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-solprices-desk/tests/desktop_security.rs
|
// file: crates/ksp-app-solprices-desk/tests/desktop_security.rs
|
||||||
// version: 5
|
// version: 6
|
||||||
|
|
||||||
//! Security, ownership and frontend instrumentation canaries for SOL Prices Desk.
|
//! Security, ownership and frontend instrumentation canaries for SOL Prices Desk.
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ fn pre_002_frontend_has_no_network_persistence_or_native_dialog_surface() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pre_005_tauri_commands_remain_centralized_and_only_single_refresh_is_present() {
|
fn pre_005_tauri_commands_remain_centralized_and_single_refresh_is_preserved() {
|
||||||
let root = app_root();
|
let root = app_root();
|
||||||
let mut rust_files = std::vec::Vec::new();
|
let mut rust_files = std::vec::Vec::new();
|
||||||
collect_files(root.join("src").as_path(), "rs", &mut rust_files);
|
collect_files(root.join("src").as_path(), "rs", &mut rust_files);
|
||||||
@@ -85,10 +85,8 @@ fn pre_005_tauri_commands_remain_centralized_and_only_single_refresh_is_present(
|
|||||||
if path.file_name().and_then(std::ffi::OsStr::to_str) != std::option::Option::Some("tauri.rs") {
|
if path.file_name().and_then(std::ffi::OsStr::to_str) != std::option::Option::Some("tauri.rs") {
|
||||||
assert!(!source.contains("tauri::generate_handler!"), "{} registers Tauri handlers outside tauri.rs", path.display());
|
assert!(!source.contains("tauri::generate_handler!"), "{} registers Tauri handlers outside tauri.rs", path.display());
|
||||||
}
|
}
|
||||||
assert!(!source.contains("refresh_market_prices"), "{} advances multi-refresh before pre.006", path.display());
|
|
||||||
assert!(!source.contains("refresh_all_market_prices"), "{} advances global refresh before pre.006", path.display());
|
|
||||||
}
|
}
|
||||||
assert_eq!(command_count, 5);
|
assert!(command_count >= 5);
|
||||||
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
assert!(tauri.contains("refresh_market_price"));
|
assert!(tauri.contains("refresh_market_price"));
|
||||||
}
|
}
|
||||||
@@ -114,3 +112,33 @@ fn pre_005_frontend_refresh_interactions_are_logged_without_business_values() {
|
|||||||
assert!(!main.contains(forbidden), "main frontend logging must not serialize business/secret values: {forbidden}");
|
assert!(!main.contains(forbidden), "main frontend logging must not serialize business/secret values: {forbidden}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_selected_and_global_refresh_commands_are_centralized_and_bounded() {
|
||||||
|
let root = app_root();
|
||||||
|
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||||
|
assert_eq!(tauri.matches("#[tauri::command]").count(), 7);
|
||||||
|
assert!(tauri.contains("refresh_market_price"));
|
||||||
|
assert!(tauri.contains("refresh_market_prices"));
|
||||||
|
assert!(tauri.contains("refresh_all_market_prices"));
|
||||||
|
let runtime = read_text(root.join("src/market_price_runtime.rs").as_path());
|
||||||
|
assert!(runtime.contains("ERROR_CODE_MARKET_PRICE_REFRESH_CONFLICT"));
|
||||||
|
assert!(runtime.contains("ERROR_CODE_MARKET_PRICE_SELECTION_INVALID"));
|
||||||
|
assert!(runtime.contains("refresh_many(provider_ids.as_slice()).await"));
|
||||||
|
assert!(runtime.contains("refresh_all().await"));
|
||||||
|
assert!(!runtime.contains("tokio::time::sleep"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_006_frontend_batch_controls_are_logged_without_price_or_provider_payloads() {
|
||||||
|
let root = app_root();
|
||||||
|
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||||
|
assert!(main.contains("SOL Prices Desk market-price selection control changed"));
|
||||||
|
assert!(main.contains("SOL Prices Desk selected market-price refresh control clicked"));
|
||||||
|
assert!(main.contains("SOL Prices Desk global market-price refresh control clicked"));
|
||||||
|
assert!(main.contains("requestedCount: result.requestedCount"));
|
||||||
|
assert!(main.contains("refreshedCount: result.refreshedCount"));
|
||||||
|
for forbidden in ["JSON.stringify(result)", "JSON.stringify(currentMarketPriceRows)", "price: provider.price", "apiKey", "authorization"] {
|
||||||
|
assert!(!main.contains(forbidden), "batch frontend logging must not serialize price/provider/secret values: {forbidden}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs
|
// file: crates/ksp-app-solprices-desk/unit_tests/market_price_runtime.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
fn descriptor() -> std::option::Option<ksp_offchain_transport_lib::MarketPriceProviderDescriptor> {
|
fn descriptor() -> std::option::Option<ksp_offchain_transport_lib::MarketPriceProviderDescriptor> {
|
||||||
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new("test-provider");
|
let provider_id = ksp_offchain_transport_lib::MarketPriceProviderId::new("test-provider");
|
||||||
@@ -230,3 +230,51 @@ async fn non_refresh_outcome_preserves_last_successful_observation() -> ksp_core
|
|||||||
}
|
}
|
||||||
return std::result::Result::Ok(());
|
return std::result::Result::Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_loading_validation_rejects_duplicates_without_partial_state_mutation() -> ksp_core_lib::Result<()> {
|
||||||
|
let provider_id = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut entries = std::collections::BTreeMap::new();
|
||||||
|
entries.insert(provider_id.as_str().to_owned(), super::MarketPricePresentationEntry { loading: false, observation: std::option::Option::None });
|
||||||
|
let mut presentation = super::MarketPricePresentationState { entries };
|
||||||
|
let result = super::begin_refresh_in_state(&mut presentation, &[provider_id.clone(), provider_id.clone()]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let entry = presentation.entries.get(provider_id.as_str());
|
||||||
|
assert!(entry.is_some());
|
||||||
|
if let std::option::Option::Some(entry) = entry {
|
||||||
|
assert!(!entry.loading);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_loading_validation_rejects_in_flight_provider_without_marking_other_rows() -> ksp_core_lib::Result<()> {
|
||||||
|
let coinpaprika = match ksp_offchain_transport_lib::MarketPriceProviderId::new("coinpaprika") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let kraken = match ksp_offchain_transport_lib::MarketPriceProviderId::new("kraken") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let mut entries = std::collections::BTreeMap::new();
|
||||||
|
entries.insert(coinpaprika.as_str().to_owned(), super::MarketPricePresentationEntry { loading: false, observation: std::option::Option::None });
|
||||||
|
entries.insert(kraken.as_str().to_owned(), super::MarketPricePresentationEntry { loading: true, observation: std::option::Option::None });
|
||||||
|
let mut presentation = super::MarketPricePresentationState { entries };
|
||||||
|
let result = super::begin_refresh_in_state(&mut presentation, &[coinpaprika.clone(), kraken.clone()]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let coinpaprika_entry = presentation.entries.get(coinpaprika.as_str());
|
||||||
|
let kraken_entry = presentation.entries.get(kraken.as_str());
|
||||||
|
assert!(coinpaprika_entry.is_some());
|
||||||
|
assert!(kraken_entry.is_some());
|
||||||
|
if let std::option::Option::Some(entry) = coinpaprika_entry {
|
||||||
|
assert!(!entry.loading);
|
||||||
|
}
|
||||||
|
if let std::option::Option::Some(entry) = kraken_entry {
|
||||||
|
assert!(entry.loading);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|||||||
103
deltas/0.2.12/pre.006.md
Normal file
103
deltas/0.2.12/pre.006.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Delta `0.2.12-pre.006` — Refresh selected/many/all SOL/USD
|
||||||
|
|
||||||
|
## 1. Identité
|
||||||
|
|
||||||
|
```text
|
||||||
|
release : 0.2.12
|
||||||
|
tranche : pre.006
|
||||||
|
version Cargo : 0.2.12-pre.6
|
||||||
|
base : 0.2.12-pre.5.fix.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Le gate opérateur de la base est propre : audits Rust/Markdown, `cargo check --workspace`, Clippy, `cargo test -p ksp-app-solprices-desk` et `cargo test --workspace` passent.
|
||||||
|
|
||||||
|
## 2. Scope réalisé
|
||||||
|
|
||||||
|
`pre.006` complète les actions manuelles du SOL Prices Desk sans ajouter de scheduler consommateur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
refresh individuel conservé
|
||||||
|
refresh selected/many MarketPriceService::refresh_many
|
||||||
|
refresh all MarketPriceService::refresh_all
|
||||||
|
ordre selected ordre courant des rows / requête
|
||||||
|
ordre all ordre stable provider-id Off-chain
|
||||||
|
DTO batch input IDs opaques uniquement
|
||||||
|
DTO batch result rows + requested_count/refreshed_count
|
||||||
|
cooldown/retry registry Off-chain, aucune réinterprétation app
|
||||||
|
polling/auto-refresh non
|
||||||
|
fallback/consensus/moyenne non
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. In-flight et atomicité
|
||||||
|
|
||||||
|
Avant chaque opération, le backend valide la sélection complète sous le mutex de présentation :
|
||||||
|
|
||||||
|
- doublons interdits ;
|
||||||
|
- IDs inconnus interdits ;
|
||||||
|
- provider déjà `loading` interdit ;
|
||||||
|
- aucune ligne n'est marquée `loading` avant la fin de cette validation globale.
|
||||||
|
|
||||||
|
Après validation, toutes les lignes concernées passent `loading = true`, puis le mutex est relâché avant l'appel async au service. En cas d'erreur structurelle du service, les flags `loading` sont nettoyés. Les outcomes réussis ou non éligibles sont appliqués dans l'ordre retourné par Off-chain Transport et conservent la dernière observation réussie lorsqu'aucune nouvelle observation n'est produite.
|
||||||
|
|
||||||
|
## 4. Frontend et Logging
|
||||||
|
|
||||||
|
La vue Prices ajoute :
|
||||||
|
|
||||||
|
```text
|
||||||
|
checkbox par ligne
|
||||||
|
compteur selected
|
||||||
|
Refresh selected
|
||||||
|
Refresh all
|
||||||
|
```
|
||||||
|
|
||||||
|
Les IDs sélectionnés sont reconstruits dans l'ordre courant des rows avant l'IPC. Les contrôles batch sont désactivés lorsqu'une opération visible est déjà `loading`.
|
||||||
|
|
||||||
|
Instrumentation :
|
||||||
|
|
||||||
|
```text
|
||||||
|
changement de sélection debug
|
||||||
|
clic Refresh selected debug + selectedCount
|
||||||
|
clic Refresh all debug + providerCount
|
||||||
|
IPC request/completion debug / trace via invokeKsp
|
||||||
|
application résultat batch trace + requested/refreshed counts
|
||||||
|
échec warn
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun prix, payload provider, URL, header, credential ou DTO complet n'est sérialisé dans les logs.
|
||||||
|
|
||||||
|
## 5. Canaris
|
||||||
|
|
||||||
|
La tranche rend les canaris historiques évolutifs puis ajoute des preuves dédiées `pre.006` :
|
||||||
|
|
||||||
|
- le refresh individuel reste présent sans interdire les surfaces futures de sa propre release ;
|
||||||
|
- `#[tauri::command]` et `generate_handler!` restent centralisés dans `tauri.rs` ;
|
||||||
|
- exactement sept commandes Tauri sont attendues dans le Desk à cette tranche ;
|
||||||
|
- `refresh_many` et `refresh_all` sont appelés directement sur le service générique ;
|
||||||
|
- doublons et conflits `in_flight` ne provoquent aucune mutation partielle ;
|
||||||
|
- aucun `setInterval`, `setTimeout`, `requestAnimationFrame` ou `tokio::time::sleep` n'est ajouté au chemin prix.
|
||||||
|
|
||||||
|
## 6. Hors scope confirmé
|
||||||
|
|
||||||
|
```text
|
||||||
|
DataTable/polish final pre.007
|
||||||
|
hardening/package final pre.008
|
||||||
|
Wallet Desk prices pre.009+
|
||||||
|
auto-refresh OUT 0.2.12
|
||||||
|
prix canonique/fallback/consensus OUT 0.2.12
|
||||||
|
valorisation USD Wallet OUT 0.2.12
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Gate
|
||||||
|
|
||||||
|
À exécuter par l'opérateur :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-app-solprices-desk
|
||||||
|
cargo test --workspace
|
||||||
|
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
|
||||||
|
```
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md -->
|
<!-- file: docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md -->
|
||||||
<!-- version: 11 -->
|
<!-- version: 12 -->
|
||||||
|
|
||||||
# Plan `0.2.12` — SOL Prices Desk + intégration prix Wallet Desk
|
# Plan `0.2.12` — SOL Prices Desk + intégration prix Wallet Desk
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ Le prix exact traverse IPC comme chaîne décimale, jamais comme `f64`. Les time
|
|||||||
|------------------------------------|----------------------------------------------------------------------------------------------------------------|--------------------|
|
|------------------------------------|----------------------------------------------------------------------------------------------------------------|--------------------|
|
||||||
| `MarketPriceRuntimeStatusDto` | app version, composite profile, offchain profile, provider counts, fallback logging | Public/safe |
|
| `MarketPriceRuntimeStatusDto` | app version, composite profile, offchain profile, provider counts, fallback logging | Public/safe |
|
||||||
| `MarketPriceProviderRowDto` | provider_id, display_name, pair, semantics, auth_mode, availability, price?, timestamps?, retry_at?, refreshed | Public/safe |
|
| `MarketPriceProviderRowDto` | provider_id, display_name, pair, semantics, auth_mode, availability, price?, timestamps?, retry_at?, refreshed | Public/safe |
|
||||||
| `MarketPriceRefreshRequestDto` | provider_id opaque | Public input borné |
|
| single refresh direct | `provider_id` opaque conservé directement depuis `pre.005` | Public input borné |
|
||||||
| `MarketPriceRefreshManyRequestDto` | provider_ids opaques bornés | Public input borné |
|
| `MarketPriceRefreshManyRequestDto` | provider_ids opaques bornés | Public input borné |
|
||||||
| `MarketPriceRefreshResultDto` | rows affectées + refreshed/classification | Public/safe |
|
| `MarketPriceRefreshResultDto` | rows affectées + refreshed/classification | Public/safe |
|
||||||
| `CommandErrorDto` | domain/code/message borné sans context sensible | Public/safe |
|
| `CommandErrorDto` | domain/code/message borné sans context sensible | Public/safe |
|
||||||
@@ -291,7 +291,7 @@ Config effective secret values
|
|||||||
|-----------------------------|--------------------------------------|----------------------------------|----------------------------------|-----------------------|
|
|-----------------------------|--------------------------------------|----------------------------------|----------------------------------|-----------------------|
|
||||||
| `get_runtime_status` | aucun | `MarketPriceRuntimeStatusDto` | AppState/Config runtime | safe |
|
| `get_runtime_status` | aucun | `MarketPriceRuntimeStatusDto` | AppState/Config runtime | safe |
|
||||||
| `list_market_prices` | aucun | `Vec<MarketPriceProviderRowDto>` | MarketPriceRuntime + registry | safe |
|
| `list_market_prices` | aucun | `Vec<MarketPriceProviderRowDto>` | MarketPriceRuntime + registry | safe |
|
||||||
| `refresh_market_price` | `provider_id` | `MarketPriceRefreshResultDto` | MarketPriceService::refresh | safe ID |
|
| `refresh_market_price` | `provider_id` | `MarketPriceProviderRowDto` | MarketPriceService::refresh | safe ID |
|
||||||
| `refresh_market_prices` | liste bornée `provider_id` | `MarketPriceRefreshResultDto` | MarketPriceService::refresh_many | safe IDs |
|
| `refresh_market_prices` | liste bornée `provider_id` | `MarketPriceRefreshResultDto` | MarketPriceService::refresh_many | safe IDs |
|
||||||
| `refresh_all_market_prices` | aucun | `MarketPriceRefreshResultDto` | MarketPriceService::refresh_all | safe |
|
| `refresh_all_market_prices` | aucun | `MarketPriceRefreshResultDto` | MarketPriceService::refresh_all | safe |
|
||||||
| `splash_frontend_ready` | fenêtre invoquante | ordre splash existant | Tauri window lifecycle | safe |
|
| `splash_frontend_ready` | fenêtre invoquante | ordre splash existant | Tauri window lifecycle | safe |
|
||||||
@@ -514,21 +514,21 @@ Le gate de `pre.004-fix.001` a révélé que `desktop_contract.rs` comparait enc
|
|||||||
|
|
||||||
### `pre.005` — Refresh individuel
|
### `pre.005` — Refresh individuel
|
||||||
|
|
||||||
**Statut : implémenté ; corrigé par `pre.005-fix.001`, gate opérateur à confirmer.**
|
**Statut : réalisé ; corrigé par `pre.005-fix.001`, gate opérateur Cargo/workspace intégralement PASS.**
|
||||||
|
|
||||||
Branchement du refresh d'une ligne via `MarketPriceService::refresh`, projection de l'observation exacte, de la fraîcheur et de l'availability, gestion d'erreur sûre et maintien du dernier état applicatif uniquement en mémoire backend. Le backend marque la ligne `loading` sans conserver le mutex de présentation pendant l'`await`, puis applique l'outcome provider-neutral ; une tentative sans nouvelle observation conserve la dernière observation réussie. L'UI ajoute un bouton `Refresh` par ligne, journalise le clic en `debug`, et n'expose encore aucun refresh multiple/global avant `pre.006`.
|
Branchement du refresh d'une ligne via `MarketPriceService::refresh`, projection de l'observation exacte, de la fraîcheur et de l'availability, gestion d'erreur sûre et maintien du dernier état applicatif uniquement en mémoire backend. Le backend marque la ligne `loading` sans conserver le mutex de présentation pendant l'`await`, puis applique l'outcome provider-neutral ; une tentative sans nouvelle observation conserve la dernière observation réussie. L'UI ajoute un bouton `Refresh` par ligne, journalise le clic en `debug`, et n'expose encore aucun refresh multiple/global avant `pre.006`.
|
||||||
|
|
||||||
#### `pre.005-fix.001` — Canari de centralisation Tauri rendu structurel
|
#### `pre.005-fix.001` — Canari de centralisation Tauri rendu structurel
|
||||||
|
|
||||||
**Statut : implémenté ; gate opérateur à confirmer.**
|
**Statut : réalisé ; gate opérateur Cargo/workspace intégralement PASS.**
|
||||||
|
|
||||||
Le gate de `pre.005` confirme que le refresh individuel fonctionne et que le runtime Tauri démarre, mais révèle un faux positif dans `desktop_security.rs` : le canari assimilait toute occurrence du nom métier `refresh_market_price` hors `tauri.rs` à du plumbing de commande. Le fix conserve l'interdiction structurelle de `#[tauri::command]` hors `tauri.rs`, ajoute l'interdiction de `tauri::generate_handler!` hors `tauri.rs`, et autorise les méthodes applicatives homonymes dans `AppState`. Aucun comportement réseau, DTO, provider, row, frontend ou commande n'est modifié.
|
Le gate de `pre.005` confirme que le refresh individuel fonctionne et que le runtime Tauri démarre, mais révèle un faux positif dans `desktop_security.rs` : le canari assimilait toute occurrence du nom métier `refresh_market_price` hors `tauri.rs` à du plumbing de commande. Le fix conserve l'interdiction structurelle de `#[tauri::command]` hors `tauri.rs`, ajoute l'interdiction de `tauri::generate_handler!` hors `tauri.rs`, et autorise les méthodes applicatives homonymes dans `AppState`. Aucun comportement réseau, DTO, provider, row, frontend ou commande n'est modifié.
|
||||||
|
|
||||||
### `pre.006` — Refresh multiple et global
|
### `pre.006` — Refresh multiple et global
|
||||||
|
|
||||||
**Statut : planifié.**
|
**Statut : implémenté ; gate opérateur à confirmer.**
|
||||||
|
|
||||||
Ajout des refresh selected/many/all, suivi `in-flight`, ordre déterministe et projection des cooldown/retry deadlines sans scheduling consommateur. Aucun polling périodique, fallback automatique ou consensus provider n'est introduit.
|
Ajout de `refresh_market_prices` et `refresh_all_market_prices` sur les APIs génériques `MarketPriceService::refresh_many`/`refresh_all`. La sélection frontend est reconstruite dans l'ordre courant des rows, le backend valide atomiquement doublons/IDs inconnus/conflits `in_flight`, marque toutes les lignes concernées `loading`, relâche le mutex de présentation pendant l'`await`, puis applique les outcomes dans leur ordre déterministe. `MarketPriceRefreshManyRequestDto` transporte uniquement les IDs opaques et `MarketPriceRefreshResultDto` retourne les rows affectées avec `requested_count`/`refreshed_count`. Les cooldown/retry deadlines restent les projections Off-chain existantes ; aucun polling, sleep consommateur, fallback automatique ou consensus provider n'est introduit.
|
||||||
|
|
||||||
### `pre.007` — UX et instrumentation SOL Prices Desk
|
### `pre.007` — UX et instrumentation SOL Prices Desk
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/validation/015-V0_2_12_SOL_PRICES_DESK.md -->
|
<!-- file: docs/validation/015-V0_2_12_SOL_PRICES_DESK.md -->
|
||||||
<!-- version: 10 -->
|
<!-- version: 11 -->
|
||||||
|
|
||||||
# Validation `0.2.12` — SOL Prices Desk + intégration Wallet Desk
|
# Validation `0.2.12` — SOL Prices Desk + intégration Wallet Desk
|
||||||
|
|
||||||
@@ -50,13 +50,13 @@ NOT RUN commande non exécutée dans le contexte indiqué
|
|||||||
|------------------------|-----------------------------------------|---------|
|
|------------------------|-----------------------------------------|---------|
|
||||||
| splash Price Desk | lifecycle KSP commun | PASS |
|
| splash Price Desk | lifecycle KSP commun | PASS |
|
||||||
| main shell Price Desk | header/navigation KSP | PASS |
|
| main shell Price Desk | header/navigation KSP | PASS |
|
||||||
| table providers | registry provider-neutral, ordre stable | PENDING |
|
| table providers | registry provider-neutral, ordre stable | PASS |
|
||||||
| prix exact | String issue de `MarketPriceDecimal` | PENDING |
|
| prix exact | String issue de `MarketPriceDecimal` | PASS |
|
||||||
| availability/fraîcheur | état générique + timestamps réels | PENDING |
|
| availability/fraîcheur | état générique + timestamps réels | PASS |
|
||||||
| refresh row | `MarketPriceService::refresh` | PENDING |
|
| refresh row | `MarketPriceService::refresh` | PASS |
|
||||||
| refresh selected/many | `MarketPriceService::refresh_many` | PENDING |
|
| refresh selected/many | `MarketPriceService::refresh_many` | PENDING |
|
||||||
| refresh all | `MarketPriceService::refresh_all` | PENDING |
|
| refresh all | `MarketPriceService::refresh_all` | PENDING |
|
||||||
| Diagnostics | profils/counts sûrs seulement | PENDING |
|
| Diagnostics | profils/counts sûrs seulement | PASS |
|
||||||
| Wallet price panel | Dashboard compact provider-neutral | PENDING |
|
| Wallet price panel | Dashboard compact provider-neutral | PENDING |
|
||||||
| auto-refresh | aucun scheduler/polling | OUT |
|
| auto-refresh | aucun scheduler/polling | OUT |
|
||||||
| Wallet USD valuation | aucun prix canonique | OUT |
|
| Wallet USD valuation | aucun prix canonique | OUT |
|
||||||
@@ -65,13 +65,13 @@ NOT RUN commande non exécutée dans le contexte indiqué
|
|||||||
|
|
||||||
| Contrat | Entrée/sortie sûre | Statut |
|
| Contrat | Entrée/sortie sûre | Statut |
|
||||||
|----------------------------------------|--------------------------------------------------------------------|---------|
|
|----------------------------------------|--------------------------------------------------------------------|---------|
|
||||||
| `MarketPriceRuntimeStatusDto` | profiles/counts sans secret | PENDING |
|
| `MarketPriceRuntimeStatusDto` | profiles/counts sans secret | PASS |
|
||||||
| `MarketPriceProviderRowDto` | ID/display/pair/semantics/auth/availability/price/timestamps/retry | PENDING |
|
| `MarketPriceProviderRowDto` | ID/display/pair/semantics/auth/availability/price/timestamps/retry | PASS |
|
||||||
| `MarketPriceRefreshRequestDto` | provider_id opaque seulement | PENDING |
|
| single refresh direct | provider_id opaque seulement | PASS |
|
||||||
| `MarketPriceRefreshManyRequestDto` | liste bornée d’IDs opaques | PENDING |
|
| `MarketPriceRefreshManyRequestDto` | liste bornée d’IDs opaques | PENDING |
|
||||||
| `MarketPriceRefreshResultDto` | rows/outcome classifiés | PENDING |
|
| `MarketPriceRefreshResultDto` | rows/outcome classifiés | PENDING |
|
||||||
| runtime/list/refresh/many/all commands | aucun URL/header/API key/body | PENDING |
|
| runtime/list/refresh/many/all commands | aucun URL/header/API key/body | PENDING |
|
||||||
| TS-RS exports | bindings générés non versionnés | PENDING |
|
| TS-RS exports | bindings générés non versionnés | PASS |
|
||||||
| provider-specific type dans IPC | interdit | OUT |
|
| provider-specific type dans IPC | interdit | OUT |
|
||||||
| prix `f64` comme vérité IPC | interdit | OUT |
|
| prix `f64` comme vérité IPC | interdit | OUT |
|
||||||
|
|
||||||
@@ -110,12 +110,12 @@ NOT RUN commande non exécutée dans le contexte indiqué
|
|||||||
|
|
||||||
| Risque/canari | Attendu | Statut |
|
| Risque/canari | Attendu | Statut |
|
||||||
|--------------------------------|----------------------------------------------|---------|
|
|--------------------------------|----------------------------------------------|---------|
|
||||||
| URL arbitraire depuis frontend | impossible par contrat command | PENDING |
|
| URL arbitraire depuis frontend | impossible par contrat command | PASS |
|
||||||
| extraction API key/header | aucune projection/log | PENDING |
|
| extraction API key/header | aucune projection/log | PASS |
|
||||||
| provider_id inconnu | erreur KSP sûre | PENDING |
|
| provider_id inconnu | erreur KSP sûre | PASS |
|
||||||
| batch duplicate/oversized | rejet avant dispatch | PENDING |
|
| batch duplicate/oversized | rejet avant dispatch | PENDING |
|
||||||
| refresh spam | limites/cooldown Off-chain toujours actifs | PENDING |
|
| refresh spam | limites/cooldown Off-chain toujours actifs | PASS |
|
||||||
| stale observation | timestamps/état distingués | PENDING |
|
| stale observation | timestamps/état distingués | PASS |
|
||||||
| double-click race | in-flight/order contrôlés | PENDING |
|
| double-click race | in-flight/order contrôlés | PENDING |
|
||||||
| close pendant refresh | aucune persistance/sûreté frontend requise | PENDING |
|
| close pendant refresh | aucune persistance/sûreté frontend requise | PENDING |
|
||||||
| CWD/resource confusion | canaris propres à chaque app | PENDING |
|
| CWD/resource confusion | canaris propres à chaque app | PENDING |
|
||||||
@@ -486,3 +486,47 @@ runtime prix / frontend / providers inchangés
|
|||||||
```
|
```
|
||||||
|
|
||||||
La version technique devient `0.2.12-pre.5.fix.1`. Le gate attendu doit confirmer le test ciblé puis le workspace complet avant d'ouvrir `pre.006`.
|
La version technique devient `0.2.12-pre.5.fix.1`. Le gate attendu doit confirmer le test ciblé puis le workspace complet avant d'ouvrir `pre.006`.
|
||||||
|
|
||||||
|
## 22. Gate `0.2.12-pre.006`
|
||||||
|
|
||||||
|
Le gate opérateur de `pre.005-fix.001` fourni le 2026-08-27 confirme la fermeture de la tranche précédente :
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all PASS
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||||
|
python3 scripts/audit_markdown_tables.py ... deltas/0.2.12 PASS
|
||||||
|
cargo check --workspace PASS
|
||||||
|
cargo clippy --workspace --all-targets PASS
|
||||||
|
cargo test -p ksp-app-solprices-desk PASS
|
||||||
|
cargo test --workspace PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
`pre.006` matérialise ensuite le batch manuel provider-neutral :
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceRefreshManyRequestDto implémenté, IDs opaques uniquement
|
||||||
|
MarketPriceRefreshResultDto implémenté, rows + requested/refreshed counts
|
||||||
|
refresh_market_prices implémenté via MarketPriceService::refresh_many
|
||||||
|
refresh_all_market_prices implémenté via MarketPriceService::refresh_all
|
||||||
|
ordre selected ordre courant des rows / requête conservé
|
||||||
|
ordre all ordre stable provider-id du service
|
||||||
|
in-flight backend validation atomique avant mutation
|
||||||
|
doublon / ID inconnu rejet sûr avant état loading partiel
|
||||||
|
mutex présentation pendant await non
|
||||||
|
cooldown / retry_at projection Off-chain existante
|
||||||
|
Refresh selected / Refresh all frontend implémentés
|
||||||
|
logging clic sélection/selected/all debug, compte/ID opaque seulement
|
||||||
|
logging résultat batch trace, requested/refreshed counts seulement
|
||||||
|
polling / setInterval / scheduler / sleep consommateur absent
|
||||||
|
fallback / consensus / moyenne absent
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations sandbox après matérialisation :
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 scripts/audit_rust_workspace_rules.py PASS
|
||||||
|
python3 scripts/audit_markdown_tables.py README.md ... deltas PASS
|
||||||
|
cargo fmt/check/clippy/test NOT RUN — cargo indisponible dans le sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
Les lignes `refresh selected/many`, `refresh all`, `MarketPriceRefreshManyRequestDto`, `MarketPriceRefreshResultDto`, ordre many, batch duplicate/oversized et double-click race de la matrice générale restent `PENDING` jusqu'au gate opérateur de cette tranche.
|
||||||
|
|||||||
Reference in New Issue
Block a user