v0.2.12-pre.007
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-solprices-desk/frontend/main.html -->
|
||||
<!-- version: 7 -->
|
||||
<!-- version: 8 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
@@ -44,13 +44,16 @@
|
||||
<div class="card app-shell-card mx-auto shadow-sm border-0">
|
||||
<div class="card-body p-4">
|
||||
<section data-view-panel="prices">
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-4">
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-3">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">Prices</h1>
|
||||
<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>
|
||||
<p class="text-body-secondary mb-0">Inventaire provider-neutral SOL/USD. Les observations restent distinctes : aucune moyenne, aucun fallback automatique et aucun polling.</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span id="selectedProviderCount" class="small text-body-secondary">0 selected</span>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap app-market-toolbar">
|
||||
<span id="selectedProviderCount" class="small text-body-secondary" aria-live="polite">0 selected</span>
|
||||
<button id="clearSelectionButton" class="btn btn-sm btn-outline-secondary" type="button" disabled>
|
||||
<i class="fa-solid fa-xmark me-1" aria-hidden="true"></i>Clear selection
|
||||
</button>
|
||||
<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>
|
||||
@@ -59,6 +62,40 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pricesSummary" class="row g-2 mb-3" aria-label="Résumé des providers">
|
||||
<div class="col-6 col-lg">
|
||||
<div class="app-market-summary-card">
|
||||
<span class="app-market-summary-label">Providers</span>
|
||||
<strong id="pricesSummaryProviders">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="app-market-summary-card">
|
||||
<span class="app-market-summary-label">Ready</span>
|
||||
<strong id="pricesSummaryReady">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="app-market-summary-card">
|
||||
<span class="app-market-summary-label">Observed</span>
|
||||
<strong id="pricesSummaryObserved">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="app-market-summary-card">
|
||||
<span class="app-market-summary-label">Attention</span>
|
||||
<strong id="pricesSummaryAttention">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg">
|
||||
<div class="app-market-summary-card">
|
||||
<span class="app-market-summary-label">Refreshing</span>
|
||||
<strong id="pricesSummaryLoading">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pricesLoading" class="app-placeholder">
|
||||
<div>
|
||||
<i class="fa-solid fa-spinner fa-spin text-body-secondary mb-3" aria-hidden="true"></i>
|
||||
@@ -73,22 +110,24 @@
|
||||
<p class="text-body-secondary mb-0">Le runtime Off-chain ne contient actuellement aucune ligne provider.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pricesDiagnostic" class="alert alert-warning mb-3" hidden></div>
|
||||
<div id="pricesTableContainer" class="table-responsive" hidden>
|
||||
<table id="marketPriceTable" class="table table-hover align-middle mb-0">
|
||||
<div id="pricesDiagnostic" class="alert alert-warning mb-3" role="status" aria-live="polite" hidden></div>
|
||||
<div id="pricesTableContainer" class="table-responsive app-market-table-container" hidden>
|
||||
<table id="marketPriceTable" class="table table-hover align-middle mb-0 app-market-table">
|
||||
<caption class="visually-hidden">Providers SOL/USD, disponibilité, prix exacts, fraîcheur et actions de refresh.</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"><span class="visually-hidden">Select</span></th>
|
||||
<th scope="col" class="app-market-selection-column">
|
||||
<input id="selectAllProviders" class="form-check-input" type="checkbox" aria-label="Select all providers" disabled>
|
||||
</th>
|
||||
<th scope="col">Provider</th>
|
||||
<th scope="col">Pair</th>
|
||||
<th scope="col">Semantics</th>
|
||||
<th scope="col">Auth</th>
|
||||
<th scope="col" class="d-none d-xl-table-cell">Semantics</th>
|
||||
<th scope="col" class="d-none d-xl-table-cell">Auth</th>
|
||||
<th scope="col">Availability</th>
|
||||
<th scope="col">Exact price</th>
|
||||
<th scope="col">Provider timestamp (ms)</th>
|
||||
<th scope="col">Received at (ms)</th>
|
||||
<th scope="col">Retry at (ms)</th>
|
||||
<th scope="col">Action</th>
|
||||
<th scope="col" class="app-market-time-column">Observation times</th>
|
||||
<th scope="col" class="app-market-time-column">Retry</th>
|
||||
<th scope="col" class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="marketPriceRows"></tbody>
|
||||
@@ -127,7 +166,7 @@
|
||||
<dt class="col-sm-5">Fallback Logging</dt>
|
||||
<dd id="runtimeLoggingFallback" class="col-sm-7">—</dd>
|
||||
</dl>
|
||||
<div id="runtimeDiagnostic" class="alert alert-warning mt-3 mb-0" hidden></div>
|
||||
<div id="runtimeDiagnostic" class="alert alert-warning mt-3 mb-0" role="status" aria-live="polite" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/sass/_app.scss
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
$app-header-height: 72px;
|
||||
$app-footer-height: 42px;
|
||||
@@ -61,7 +61,10 @@ body {
|
||||
max-width: 1320px;
|
||||
}
|
||||
|
||||
.app-runtime-list dd {
|
||||
.app-runtime-list dd,
|
||||
.app-market-code,
|
||||
.app-market-price,
|
||||
.app-market-time-exact {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
|
||||
@@ -103,3 +106,130 @@ body {
|
||||
.app-placeholder i {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.app-market-toolbar {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.app-market-summary-card {
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.app-market-summary-card strong {
|
||||
min-width: 2ch;
|
||||
font-size: 1.15rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.app-market-summary-label {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.app-market-table-container {
|
||||
max-height: min(62vh, 720px);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
|
||||
.app-market-table {
|
||||
min-width: 1080px;
|
||||
}
|
||||
|
||||
.app-market-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--bs-tertiary-bg);
|
||||
box-shadow: inset 0 -1px 0 var(--bs-border-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-market-table tbody tr[data-availability="ready"] {
|
||||
--bs-table-accent-bg: rgba($success, 0.025);
|
||||
}
|
||||
|
||||
.app-market-table tbody tr[data-availability="cooling_down"],
|
||||
.app-market-table tbody tr[data-availability="temporarily_unavailable"] {
|
||||
--bs-table-accent-bg: rgba($warning, 0.04);
|
||||
}
|
||||
|
||||
.app-market-table tbody tr[data-availability="authentication_unavailable"],
|
||||
.app-market-table tbody tr[data-availability="misconfigured"] {
|
||||
--bs-table-accent-bg: rgba($danger, 0.035);
|
||||
}
|
||||
|
||||
.app-market-selection-column {
|
||||
width: 42px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-market-time-column {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.app-market-provider-name,
|
||||
.app-market-price {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-market-code,
|
||||
.app-market-time-exact {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.app-market-price {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-market-time {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.app-market-time-line {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.35rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.app-market-time-label {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-market-time-value {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-market-row-loading {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.app-sidebar {
|
||||
width: 190px;
|
||||
min-width: 190px;
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
.app-market-toolbar {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-solprices-desk/frontend/ts/main.ts
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
import "bootstrap";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
@@ -17,11 +17,46 @@ installFrontendConsoleBridge("main");
|
||||
|
||||
type ViewId = "prices" | "diagnostics";
|
||||
|
||||
type AvailabilityPresentation = {
|
||||
badgeClass: string;
|
||||
iconClass: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const viewTitles: Record<ViewId, string> = {
|
||||
prices: "Prices",
|
||||
diagnostics: "Diagnostics",
|
||||
};
|
||||
|
||||
const availabilityPresentations: Record<string, AvailabilityPresentation> = {
|
||||
authentication_unavailable: { badgeClass: "text-bg-danger", iconClass: "fa-key", label: "Authentication unavailable" },
|
||||
cooling_down: { badgeClass: "text-bg-warning", iconClass: "fa-clock", label: "Cooling down" },
|
||||
disabled: { badgeClass: "text-bg-secondary", iconClass: "fa-ban", label: "Disabled" },
|
||||
misconfigured: { badgeClass: "text-bg-danger", iconClass: "fa-triangle-exclamation", label: "Misconfigured" },
|
||||
quota_unavailable: { badgeClass: "text-bg-warning", iconClass: "fa-gauge-high", label: "Quota unavailable" },
|
||||
ready: { badgeClass: "text-bg-success", iconClass: "fa-circle-check", label: "Ready" },
|
||||
temporarily_unavailable: { badgeClass: "text-bg-warning", iconClass: "fa-cloud-arrow-down", label: "Temporarily unavailable" },
|
||||
unknown: { badgeClass: "text-bg-secondary", iconClass: "fa-circle-question", label: "Unknown" },
|
||||
};
|
||||
|
||||
const authLabels: Record<string, string> = {
|
||||
none: "Keyless",
|
||||
optional_api_key: "Optional API key",
|
||||
required_api_key: "API key required",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
const semanticsLabels: Record<string, string> = {
|
||||
aggregated_market: "Aggregated market",
|
||||
dex_pair_usd: "DEX pair USD",
|
||||
exchange_last_trade: "Exchange last trade",
|
||||
solana_heuristic: "Solana heuristic",
|
||||
solana_spot: "Solana spot",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
const jsDateMaxUnixMillis = 8_640_000_000_000_000n;
|
||||
|
||||
let currentMarketPriceRows: MarketPriceProviderRowDto[] = [];
|
||||
const selectedProviderIds = new Set<string>();
|
||||
|
||||
@@ -59,10 +94,14 @@ function bindNavigation(): void {
|
||||
frontendTrace("main", "SOL Prices Desk navigation handlers installed");
|
||||
}
|
||||
|
||||
function appendMarketPriceCell(row: HTMLTableRowElement, value: string): void {
|
||||
function appendMarketPriceCell(row: HTMLTableRowElement, value: string, className?: string): HTMLTableCellElement {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = value;
|
||||
if (className) {
|
||||
cell.className = className;
|
||||
}
|
||||
row.append(cell);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function clearPricesDiagnostic(): void {
|
||||
@@ -94,21 +133,49 @@ function synchronizeSelectedProviders(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function updateSummary(): void {
|
||||
const summaryValues: Record<string, number> = {
|
||||
pricesSummaryProviders: currentMarketPriceRows.length,
|
||||
pricesSummaryReady: currentMarketPriceRows.filter(provider => provider.availability === "ready" && !provider.loading).length,
|
||||
pricesSummaryObserved: currentMarketPriceRows.filter(provider => provider.refreshed).length,
|
||||
pricesSummaryAttention: currentMarketPriceRows.filter(provider => provider.availability !== "ready").length,
|
||||
pricesSummaryLoading: currentMarketPriceRows.filter(provider => provider.loading).length,
|
||||
};
|
||||
for (const [elementId, value] of Object.entries(summaryValues)) {
|
||||
const element = document.querySelector<HTMLElement>(`#${elementId}`);
|
||||
if (element) {
|
||||
element.textContent = value.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function synchronizeBatchControls(): void {
|
||||
const selectedCount = selectedProviderIdsInRowOrder().length;
|
||||
const providerCount = currentMarketPriceRows.length;
|
||||
const anyLoading = currentMarketPriceRows.some(provider => provider.loading);
|
||||
const selectedButton = document.querySelector<HTMLButtonElement>("#refreshSelectedButton");
|
||||
const allButton = document.querySelector<HTMLButtonElement>("#refreshAllButton");
|
||||
const clearButton = document.querySelector<HTMLButtonElement>("#clearSelectionButton");
|
||||
const selectAll = document.querySelector<HTMLInputElement>("#selectAllProviders");
|
||||
const selectedCountLabel = document.querySelector<HTMLElement>("#selectedProviderCount");
|
||||
if (selectedButton) {
|
||||
selectedButton.disabled = selectedCount === 0 || anyLoading;
|
||||
}
|
||||
if (allButton) {
|
||||
allButton.disabled = currentMarketPriceRows.length === 0 || anyLoading;
|
||||
allButton.disabled = providerCount === 0 || anyLoading;
|
||||
}
|
||||
if (clearButton) {
|
||||
clearButton.disabled = selectedCount === 0 || anyLoading;
|
||||
}
|
||||
if (selectAll) {
|
||||
selectAll.disabled = providerCount === 0 || anyLoading;
|
||||
selectAll.checked = providerCount !== 0 && selectedCount === providerCount;
|
||||
selectAll.indeterminate = selectedCount > 0 && selectedCount < providerCount;
|
||||
}
|
||||
if (selectedCountLabel) {
|
||||
selectedCountLabel.textContent = `${selectedCount} selected`;
|
||||
}
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function markProvidersLoading(providerIds: string[]): void {
|
||||
@@ -198,6 +265,7 @@ async function refreshAllMarketPrices(): Promise<void> {
|
||||
|
||||
function appendSelectionCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "app-market-selection-column";
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.className = "form-check-input";
|
||||
@@ -220,13 +288,152 @@ function appendSelectionCell(row: HTMLTableRowElement, provider: MarketPriceProv
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function appendProviderCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
const name = document.createElement("div");
|
||||
name.className = "app-market-provider-name";
|
||||
name.textContent = provider.displayName;
|
||||
const code = document.createElement("div");
|
||||
code.className = "app-market-code";
|
||||
code.textContent = provider.providerId;
|
||||
cell.append(name, code);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function appendBadgeCell(row: HTMLTableRowElement, label: string, badgeClass = "text-bg-light", className?: string): void {
|
||||
const cell = document.createElement("td");
|
||||
if (className) {
|
||||
cell.className = className;
|
||||
}
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `badge ${badgeClass}`;
|
||||
badge.textContent = label;
|
||||
cell.append(badge);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function availabilityPresentation(provider: MarketPriceProviderRowDto): AvailabilityPresentation {
|
||||
if (provider.loading) {
|
||||
return { badgeClass: "text-bg-info", iconClass: "fa-spinner fa-spin", label: "Refreshing" };
|
||||
}
|
||||
return availabilityPresentations[provider.availability] ?? availabilityPresentations.unknown;
|
||||
}
|
||||
|
||||
function appendAvailabilityCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
const presentation = availabilityPresentation(provider);
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `badge ${presentation.badgeClass}`;
|
||||
const icon = document.createElement("i");
|
||||
icon.className = `fa-solid ${presentation.iconClass} me-1`;
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
badge.append(icon, document.createTextNode(presentation.label));
|
||||
cell.append(badge);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function appendPriceCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
if (provider.price === null) {
|
||||
const empty = document.createElement("span");
|
||||
empty.className = "text-body-secondary";
|
||||
empty.textContent = "No observation";
|
||||
cell.append(empty);
|
||||
} else {
|
||||
const price = document.createElement("span");
|
||||
price.className = "app-market-price";
|
||||
price.textContent = provider.price;
|
||||
const currency = document.createElement("small");
|
||||
currency.className = "text-body-secondary ms-1";
|
||||
currency.textContent = "USD";
|
||||
cell.append(price, currency);
|
||||
}
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function formatUnixMillis(value: string): { exact: string; iso: string; local: string } | null {
|
||||
try {
|
||||
const exact = BigInt(value);
|
||||
if (exact < -jsDateMaxUnixMillis || exact > jsDateMaxUnixMillis) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(Number(exact));
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
exact: value,
|
||||
iso: date.toISOString(),
|
||||
local: date.toLocaleString("fr-CH", { dateStyle: "short", timeStyle: "medium", hour12: false }),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendTimestampValue(container: HTMLElement, label: string, value: string | null, emptyLabel: string): void {
|
||||
const line = document.createElement("div");
|
||||
line.className = "app-market-time-line";
|
||||
const prefix = document.createElement("span");
|
||||
prefix.className = "app-market-time-label";
|
||||
prefix.textContent = label;
|
||||
const valueContainer = document.createElement("span");
|
||||
valueContainer.className = "app-market-time-value";
|
||||
if (value === null) {
|
||||
valueContainer.classList.add("text-body-secondary");
|
||||
valueContainer.textContent = emptyLabel;
|
||||
} else {
|
||||
const formatted = formatUnixMillis(value);
|
||||
if (formatted === null) {
|
||||
const exact = document.createElement("span");
|
||||
exact.className = "app-market-time-exact";
|
||||
exact.textContent = `${value} ms`;
|
||||
valueContainer.append(exact);
|
||||
} else {
|
||||
const time = document.createElement("time");
|
||||
time.dateTime = formatted.iso;
|
||||
time.textContent = formatted.local;
|
||||
time.title = formatted.iso;
|
||||
const exact = document.createElement("span");
|
||||
exact.className = "app-market-time-exact d-block";
|
||||
exact.textContent = `${formatted.exact} ms`;
|
||||
valueContainer.append(time, exact);
|
||||
}
|
||||
}
|
||||
line.append(prefix, valueContainer);
|
||||
container.append(line);
|
||||
}
|
||||
|
||||
function appendObservationTimeCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "app-market-time-column";
|
||||
const time = document.createElement("div");
|
||||
time.className = "app-market-time";
|
||||
appendTimestampValue(time, "Provider", provider.providerTimestampUnixMillis, "Not supplied");
|
||||
appendTimestampValue(time, "KSP", provider.receivedAtUnixMillis, "—");
|
||||
cell.append(time);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function appendRetryCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "app-market-time-column";
|
||||
const time = document.createElement("div");
|
||||
time.className = "app-market-time";
|
||||
appendTimestampValue(time, "Retry", provider.retryAtUnixMillis, "—");
|
||||
cell.append(time);
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function appendRefreshCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.className = "text-end";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn btn-sm btn-outline-primary text-nowrap";
|
||||
button.disabled = provider.loading;
|
||||
button.dataset.providerId = provider.providerId;
|
||||
button.setAttribute("aria-label", `Refresh ${provider.displayName}`);
|
||||
button.innerHTML = provider.loading
|
||||
? '<i class="fa-solid fa-spinner fa-spin me-1" aria-hidden="true"></i>Refreshing…'
|
||||
: '<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>Refresh';
|
||||
@@ -257,34 +464,68 @@ function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void {
|
||||
for (const provider of rows) {
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.providerId = provider.providerId;
|
||||
row.dataset.availability = provider.availability;
|
||||
row.classList.toggle("app-market-row-loading", provider.loading);
|
||||
appendSelectionCell(row, provider);
|
||||
appendMarketPriceCell(row, provider.displayName);
|
||||
appendMarketPriceCell(row, provider.pair);
|
||||
appendMarketPriceCell(row, provider.semantics);
|
||||
appendMarketPriceCell(row, provider.authMode);
|
||||
appendMarketPriceCell(row, provider.loading ? "loading" : provider.availability);
|
||||
appendMarketPriceCell(row, provider.price ?? "—");
|
||||
appendMarketPriceCell(row, provider.providerTimestampUnixMillis ?? "—");
|
||||
appendMarketPriceCell(row, provider.receivedAtUnixMillis ?? "—");
|
||||
appendMarketPriceCell(row, provider.retryAtUnixMillis ?? "—");
|
||||
appendProviderCell(row, provider);
|
||||
appendBadgeCell(row, provider.pair, "text-bg-primary");
|
||||
appendBadgeCell(row, semanticsLabels[provider.semantics] ?? semanticsLabels.unknown, "text-bg-light", "d-none d-xl-table-cell");
|
||||
appendBadgeCell(row, authLabels[provider.authMode] ?? authLabels.unknown, "text-bg-light", "d-none d-xl-table-cell");
|
||||
appendAvailabilityCell(row, provider);
|
||||
appendPriceCell(row, provider);
|
||||
appendObservationTimeCell(row, provider);
|
||||
appendRetryCell(row, provider);
|
||||
appendRefreshCell(row, provider);
|
||||
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,
|
||||
observedCount: rows.filter(provider => provider.refreshed).length,
|
||||
attentionCount: rows.filter(provider => provider.availability !== "ready").length,
|
||||
});
|
||||
}
|
||||
|
||||
function selectAllProviders(selected: boolean): void {
|
||||
selectedProviderIds.clear();
|
||||
if (selected) {
|
||||
for (const provider of currentMarketPriceRows) {
|
||||
selectedProviderIds.add(provider.providerId);
|
||||
}
|
||||
}
|
||||
frontendDebug("main", "SOL Prices Desk select-all providers control changed", {
|
||||
selected,
|
||||
selectedCount: selectedProviderIds.size,
|
||||
});
|
||||
renderMarketPriceRows(currentMarketPriceRows);
|
||||
}
|
||||
|
||||
function clearProviderSelection(): void {
|
||||
const selectedCount = selectedProviderIds.size;
|
||||
frontendDebug("main", "SOL Prices Desk clear provider selection control clicked", { selectedCount });
|
||||
selectedProviderIds.clear();
|
||||
renderMarketPriceRows(currentMarketPriceRows);
|
||||
}
|
||||
|
||||
function bindMarketPriceControls(): void {
|
||||
const selectedButton = document.querySelector<HTMLButtonElement>("#refreshSelectedButton");
|
||||
const allButton = document.querySelector<HTMLButtonElement>("#refreshAllButton");
|
||||
const clearButton = document.querySelector<HTMLButtonElement>("#clearSelectionButton");
|
||||
const selectAll = document.querySelector<HTMLInputElement>("#selectAllProviders");
|
||||
selectedButton?.addEventListener("click", () => {
|
||||
void refreshSelectedMarketPrices();
|
||||
});
|
||||
allButton?.addEventListener("click", () => {
|
||||
void refreshAllMarketPrices();
|
||||
});
|
||||
clearButton?.addEventListener("click", () => {
|
||||
clearProviderSelection();
|
||||
});
|
||||
selectAll?.addEventListener("change", () => {
|
||||
selectAllProviders(selectAll.checked);
|
||||
});
|
||||
synchronizeBatchControls();
|
||||
frontendTrace("main", "SOL Prices Desk market-price batch controls installed");
|
||||
frontendTrace("main", "SOL Prices Desk market-price controls installed");
|
||||
}
|
||||
|
||||
function renderRuntimeStatus(status: MarketPriceRuntimeStatusDto): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ksp-app-solprices-desk",
|
||||
"private": true,
|
||||
"version": "0.2.12-pre.6",
|
||||
"version": "0.2.12-pre.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/app_state.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
//! Shared backend state owned by the SOL Prices Desk Tauri application.
|
||||
|
||||
@@ -129,7 +129,7 @@ impl crate::AppState {
|
||||
fallback_logging_active: runtime.fallback_active,
|
||||
provider_count,
|
||||
ready_provider_count,
|
||||
shell_phase: "pre.006-multi-refresh".to_owned(),
|
||||
shell_phase: "pre.007-ux-instrumentation".to_owned(),
|
||||
startup_diagnostic: runtime.startup_diagnostic.clone(),
|
||||
unavailable_provider_count,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-solprices-desk/src/tauri.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
//! Tauri runtime assembly for the KSP SOL prices desktop application.
|
||||
|
||||
@@ -98,12 +98,24 @@ fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri:
|
||||
});
|
||||
}
|
||||
|
||||
fn project_command_error(command: &'static str, domain: &'static str, error: &ksp_core_lib::Error) -> crate::CommandErrorDto {
|
||||
ksp_logging_lib::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = domain,
|
||||
command = command,
|
||||
error_domain = error.code().domain(),
|
||||
error_code = error.code().code(),
|
||||
"SOL Prices Desk Tauri command failed"
|
||||
);
|
||||
return crate::CommandErrorDto::from_error(error);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> {
|
||||
let result = crate::emit_frontend_log_event(payload);
|
||||
return match result {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("emit_frontend_log", crate::TRACING_DOMAIN_FRONTEND, &error)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +124,7 @@ fn get_runtime_status(state: tauri::State<'_, crate::AppState>) -> std::result::
|
||||
let result = state.runtime_status();
|
||||
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)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("get_runtime_status", crate::TRACING_DOMAIN_SHELL, &error)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,10 +135,17 @@ fn list_market_prices(
|
||||
let result = state.list_market_prices();
|
||||
return match result {
|
||||
std::result::Result::Ok(value) => {
|
||||
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, row_count = value.len(), "projected SOL Prices Desk market-price registry rows");
|
||||
ksp_logging_lib::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
|
||||
row_count = value.len(),
|
||||
"projected SOL Prices Desk market-price registry rows"
|
||||
);
|
||||
std::result::Result::Ok(value)
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(project_command_error("list_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,7 +156,9 @@ async fn refresh_all_market_prices(
|
||||
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)),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(project_command_error("refresh_all_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,7 +170,9 @@ async fn refresh_market_price(
|
||||
let result = state.refresh_market_price(provider_id).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)),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(project_command_error("refresh_market_price", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,7 +184,9 @@ async fn refresh_market_prices(
|
||||
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)),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(project_command_error("refresh_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,6 +199,6 @@ async fn splash_frontend_ready(
|
||||
let result = crate::splash_frontend_ready_service(app, webview_window, &state).await;
|
||||
return match result {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("splash_frontend_ready", crate::TRACING_DOMAIN_WINDOWS, &error)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KSP SOL Prices Desk",
|
||||
"version": "0.2.12-pre.6",
|
||||
"version": "0.2.12-pre.7",
|
||||
"identifier": "com.sasedev.ksp-app-solprices-desk",
|
||||
"build": {
|
||||
"beforeDevCommand": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
//! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`.
|
||||
|
||||
@@ -246,3 +246,46 @@ fn pre_006_market_price_runtime_adds_selected_and_global_refresh_without_polling
|
||||
assert!(!main_typescript.contains(forbidden), "pre.006 must not add consumer scheduling: {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_prices_view_is_responsive_and_projects_generic_states_without_price_coercion() {
|
||||
let root = app_root();
|
||||
let main_html = read_text(root.join("frontend/main.html").as_path());
|
||||
for required in [
|
||||
r#"id="pricesSummaryProviders""#,
|
||||
r#"id="pricesSummaryReady""#,
|
||||
r#"id="pricesSummaryObserved""#,
|
||||
r#"id="pricesSummaryAttention""#,
|
||||
r#"id="pricesSummaryLoading""#,
|
||||
r#"id="selectAllProviders""#,
|
||||
r#"id="clearSelectionButton""#,
|
||||
"Observation times",
|
||||
"app-market-table-container",
|
||||
"visually-hidden",
|
||||
] {
|
||||
assert!(main_html.contains(required), "pre.007 Prices UX is missing {required}");
|
||||
}
|
||||
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
for required in [
|
||||
"availabilityPresentations",
|
||||
"Authentication unavailable",
|
||||
"Cooling down",
|
||||
"Quota unavailable",
|
||||
"Temporarily unavailable",
|
||||
"formatUnixMillis",
|
||||
"BigInt(value)",
|
||||
"Not supplied",
|
||||
"selectAllProviders",
|
||||
"clearProviderSelection",
|
||||
"pricesSummaryObserved",
|
||||
"app-market-row-loading",
|
||||
] {
|
||||
assert!(main_typescript.contains(required), "pre.007 Prices UX is missing {required}");
|
||||
}
|
||||
assert!(!main_typescript.contains("parseFloat("));
|
||||
assert!(!main_typescript.contains("Number(provider.price"));
|
||||
let app_scss = read_text(root.join("frontend/sass/_app.scss").as_path());
|
||||
for required in ["position: sticky", "font-variant-numeric: tabular-nums", "app-market-summary-card", "app-market-table-container"] {
|
||||
assert!(app_scss.contains(required), "pre.007 responsive styling is missing {required}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-solprices-desk/tests/desktop_security.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
//! Security, ownership and frontend instrumentation canaries for SOL Prices Desk.
|
||||
|
||||
@@ -142,3 +142,47 @@ fn pre_006_frontend_batch_controls_are_logged_without_price_or_provider_payloads
|
||||
assert!(!main.contains(forbidden), "batch frontend logging must not serialize price/provider/secret values: {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_all_frontend_controls_and_backend_command_failures_are_instrumented_safely() {
|
||||
let root = app_root();
|
||||
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
for required in [
|
||||
"SOL Prices Desk navigation control clicked",
|
||||
"SOL Prices Desk market-price selection control changed",
|
||||
"SOL Prices Desk select-all providers control changed",
|
||||
"SOL Prices Desk clear provider selection control clicked",
|
||||
"SOL Prices Desk market-price refresh control clicked",
|
||||
"SOL Prices Desk selected market-price refresh control clicked",
|
||||
"SOL Prices Desk global market-price refresh control clicked",
|
||||
] {
|
||||
assert!(main.contains(required), "pre.007 frontend control instrumentation is missing {required}");
|
||||
}
|
||||
for forbidden in [
|
||||
"JSON.stringify(currentMarketPriceRows)",
|
||||
"JSON.stringify(result)",
|
||||
"price: provider.price",
|
||||
"providerTimestampUnixMillis:",
|
||||
"receivedAtUnixMillis:",
|
||||
"retryAtUnixMillis:",
|
||||
"apiKey",
|
||||
"authorization",
|
||||
] {
|
||||
assert!(!main.contains(forbidden), "pre.007 frontend instrumentation must not log business/secret values: {forbidden}");
|
||||
}
|
||||
let tauri = read_text(root.join("src/tauri.rs").as_path());
|
||||
assert!(tauri.contains("fn project_command_error"));
|
||||
assert!(tauri.contains("error_domain = error.code().domain()"));
|
||||
assert!(tauri.contains("error_code = error.code().code()"));
|
||||
assert!(tauri.contains("SOL Prices Desk Tauri command failed"));
|
||||
assert!(!tauri.contains("error_message = error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_007_remains_manual_without_browser_persistence_or_price_scheduler() {
|
||||
let root = app_root();
|
||||
let main = read_text(root.join("frontend/ts/main.ts").as_path());
|
||||
for forbidden in ["setInterval(", "setTimeout(", "requestAnimationFrame(", "localStorage", "sessionStorage", "fetch(", "XMLHttpRequest"] {
|
||||
assert!(!main.contains(forbidden), "pre.007 must remain manual and backend-owned: {forbidden}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user