v0.3.7-pre.007

This commit is contained in:
2026-09-02 16:07:57 +02:00
parent 6fc5b5b607
commit 9c4469248a
21 changed files with 1518 additions and 67 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-backfill-desk/frontend/main.html -->
<!-- version: 2 -->
<!-- version: 3 -->
<!DOCTYPE html>
<html lang="fr">
@@ -47,12 +47,12 @@
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-4">
<div>
<h1 class="h3 mb-1">Backfill</h1>
<p class="text-body-secondary mb-0">Scaffold desktop prêt. Les contrôles Backfill seront branchés dans les tranches runtime suivantes.</p>
<p class="text-body-secondary mb-0">Mainnet par défaut, transport HTTP sélectionnable et mapping de campagne validé côté Rust avant tout démarrage de job.</p>
</div>
<span id="appVersionBadge" class="badge text-bg-secondary"></span>
</div>
<div class="row g-4">
<div class="col-xl-7">
<div class="col-xl-4">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold">Transport HTTP</div>
<div class="card-body">
@@ -71,12 +71,77 @@
</div>
</div>
</div>
<div class="col-xl-5">
<div class="app-placeholder h-100">
<i class="fa-solid fa-clock-rotate-left text-body-secondary" aria-hidden="true"></i>
<div>
<h2 class="h5">Campagne Backfill</h2>
<p class="text-body-secondary mb-0">Les scopes, commitments et le bouton Start seront branchés dans la tranche suivante. Le transport reste HTTP uniquement en v0.3.7.</p>
<div class="col-xl-8">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold">Campagne Backfill</div>
<div class="card-body">
<form id="backfillCampaignForm" novalidate>
<div class="row g-3">
<div class="col-lg-6">
<label for="backfillScopeKind" class="form-label">Scope</label>
<select id="backfillScopeKind" class="form-select" disabled></select>
</div>
<div class="col-lg-6">
<label for="backfillCommitment" class="form-label">Commitment</label>
<select id="backfillCommitment" class="form-select" disabled></select>
</div>
<div id="backfillAddressGroup" class="col-12">
<label for="backfillAddress" class="form-label">Adresse Solana</label>
<input id="backfillAddress" class="form-control font-monospace" type="text" autocomplete="off" spellcheck="false">
</div>
<div id="backfillAnchorGroup" class="col-12" hidden>
<label for="backfillAnchorSignature" class="form-label">Signature d'ancrage exclusive</label>
<input id="backfillAnchorSignature" class="form-control font-monospace" type="text" autocomplete="off" spellcheck="false">
</div>
<div id="backfillExplicitSignaturesGroup" class="col-12" hidden>
<label for="backfillExplicitSignatures" class="form-label">Signatures explicites</label>
<textarea id="backfillExplicitSignatures" class="form-control font-monospace" rows="5" autocomplete="off" spellcheck="false"></textarea>
<div id="backfillExplicitSignaturesHelp" class="form-text">Une signature par ligne.</div>
</div>
<div class="col-md-6 col-xxl-3">
<label for="backfillPageSize" class="form-label">Page size</label>
<input id="backfillPageSize" class="form-control" type="number" step="1">
</div>
<div class="col-md-6 col-xxl-3">
<label for="backfillMaxPages" class="form-label">Max pages</label>
<input id="backfillMaxPages" class="form-control" type="number" step="1">
</div>
<div class="col-md-6 col-xxl-3">
<label for="backfillMaxCandidates" class="form-label">Max candidates</label>
<input id="backfillMaxCandidates" class="form-control" type="number" step="1">
</div>
<div class="col-md-6 col-xxl-3">
<label for="backfillHydrationConcurrency" class="form-label">Concurrence</label>
<input id="backfillHydrationConcurrency" class="form-control" type="number" step="1">
</div>
<div id="backfillMinContextSlotGroup" class="col-lg-6">
<label for="backfillMinContextSlot" class="form-label">Min context slot</label>
<input id="backfillMinContextSlot" class="form-control font-monospace" type="text" inputmode="numeric" autocomplete="off" spellcheck="false" placeholder="optionnel">
<div class="form-text">Chaîne décimale validée côté Rust pour préserver la précision u64.</div>
</div>
</div>
<div class="d-flex align-items-center gap-3 flex-wrap mt-4">
<button id="validateBackfillRequest" class="btn btn-primary" type="submit" disabled>
<i class="fa-solid fa-shield-halved me-2" aria-hidden="true"></i>Valider la requête
</button>
<span class="text-body-secondary small">Cette tranche ne démarre aucun job.</span>
</div>
</form>
<div id="backfillRequestValidation" class="alert alert-secondary mt-4 mb-0" role="status" aria-live="polite" hidden></div>
<div id="backfillRequestPreview" class="card bg-body-tertiary border-0 mt-3" hidden>
<div class="card-body">
<h2 class="h6 mb-3">Projection backend sûre</h2>
<dl class="row mb-0 app-runtime-list">
<dt class="col-sm-5">Réseau</dt><dd id="validatedNetwork" class="col-sm-7"></dd>
<dt class="col-sm-5">Route HTTP</dt><dd id="validatedHttpRole" class="col-sm-7"></dd>
<dt class="col-sm-5">Commitment</dt><dd id="validatedCommitment" class="col-sm-7"></dd>
<dt class="col-sm-5">Scope</dt><dd id="validatedScopeKind" class="col-sm-7"></dd>
<dt class="col-sm-5">Signatures explicites</dt><dd id="validatedExplicitSignatureCount" class="col-sm-7"></dd>
<dt class="col-sm-5">page/pages/candidates/concurrence</dt><dd id="validatedBounds" class="col-sm-7"></dd>
<dt class="col-sm-5">Min context slot</dt><dd id="validatedMinContextSlot" class="col-sm-7"></dd>
</dl>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,10 +1,12 @@
// file: crates/ksp-app-backfill-desk/frontend/ts/main.ts
// version: 2
// version: 3
import "bootstrap";
import ResizeObserver from "resize-observer-polyfill";
import "simplebar";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { BackfillRequestPreviewDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillRequestPreviewDto.ts";
import type { BackfillStartRequestDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartRequestDto.ts";
import type { BackfillDeskOptionsDto } from "./bindings/ksp_app_backfill_desk/dto_common/BackfillDeskOptionsDto.ts";
import type { BackfillHttpRouteOptionDto } from "./bindings/ksp_app_backfill_desk/dto_common/BackfillHttpRouteOptionDto.ts";
import type { ShellStatusDto } from "./bindings/ksp_app_backfill_desk/dto_common/ShellStatusDto.ts";
@@ -21,6 +23,19 @@ const viewTitles: Record<ViewId, string> = {
diagnostics: "Diagnostics",
};
const commitmentLabels: Record<string, string> = {
confirmed: "confirmed",
finalized: "finalized",
};
const scopeLabels: Record<string, string> = {
latest_address: "Adresse — historique le plus récent",
before_address: "Adresse — avant une signature",
after_address: "Adresse — après une signature",
explicit_signatures: "Signatures explicites",
};
function isViewId(value: string | undefined): value is ViewId {
return value === "backfill" || value === "diagnostics";
}
@@ -79,6 +94,245 @@ function bindNavigation(): void {
frontendTrace("main", "Backfill Desk navigation handlers installed");
}
function populateCodeSelect(selectId: string, values: string[], labels: Record<string, string>): void {
const select = document.querySelector<HTMLSelectElement>(`#${selectId}`);
if (!select) {
return;
}
const previous = select.value;
select.replaceChildren();
for (const value of values) {
const option = document.createElement("option");
option.value = value;
option.textContent = labels[value] ?? value;
select.append(option);
}
select.disabled = values.length === 0;
if (values.includes(previous)) {
select.value = previous;
}
}
function applyNumberInputContract(inputId: string, maximum: number, defaultValue: number): void {
const input = document.querySelector<HTMLInputElement>(`#${inputId}`);
if (!input) {
return;
}
input.min = "1";
input.max = maximum.toString();
if (input.value === "") {
input.value = defaultValue.toString();
}
}
function renderCampaignContract(options: BackfillDeskOptionsDto): void {
populateCodeSelect("backfillScopeKind", options.scopeKinds, scopeLabels);
populateCodeSelect("backfillCommitment", options.commitments, commitmentLabels);
applyNumberInputContract("backfillPageSize", options.limits.maxPageSize, options.limits.defaultPageSize);
applyNumberInputContract("backfillMaxPages", options.limits.maxPages, options.limits.defaultMaxPages);
applyNumberInputContract("backfillMaxCandidates", options.limits.maxCandidates, options.limits.defaultMaxCandidates);
applyNumberInputContract("backfillHydrationConcurrency", options.limits.maxHydrationConcurrency, options.limits.defaultHydrationConcurrency);
const signatureHelp = document.querySelector<HTMLElement>("#backfillExplicitSignaturesHelp");
if (signatureHelp) {
signatureHelp.textContent = `Une signature par ligne, ${options.limits.minSignatureTextBytes}${options.limits.maxSignatureTextBytes} octets encodés ; maximum ${options.limits.maxCandidates}.`;
}
const validateButton = document.querySelector<HTMLButtonElement>("#validateBackfillRequest");
if (validateButton) {
validateButton.disabled = !options.compositionReady || options.httpRoutes.length === 0;
}
updateCampaignScopeFields();
frontendTrace("main", "Backfill Desk campaign contract rendered", {
commitmentCount: options.commitments.length,
compositionReady: options.compositionReady,
scopeCount: options.scopeKinds.length,
});
}
function currentScopeKind(): string {
const select = document.querySelector<HTMLSelectElement>("#backfillScopeKind");
return select?.value ?? "";
}
function updateCampaignScopeFields(): void {
const scopeKind = currentScopeKind();
const addressGroup = document.querySelector<HTMLElement>("#backfillAddressGroup");
const anchorGroup = document.querySelector<HTMLElement>("#backfillAnchorGroup");
const signaturesGroup = document.querySelector<HTMLElement>("#backfillExplicitSignaturesGroup");
const minContextSlotGroup = document.querySelector<HTMLElement>("#backfillMinContextSlotGroup");
const addressScope = scopeKind === "latest_address" || scopeKind === "before_address" || scopeKind === "after_address";
const anchoredScope = scopeKind === "before_address" || scopeKind === "after_address";
const explicitScope = scopeKind === "explicit_signatures";
if (addressGroup) {
addressGroup.hidden = !addressScope;
}
if (anchorGroup) {
anchorGroup.hidden = !anchoredScope;
}
if (signaturesGroup) {
signaturesGroup.hidden = !explicitScope;
}
if (minContextSlotGroup) {
minContextSlotGroup.hidden = explicitScope;
}
frontendTrace("main", "Backfill Desk campaign scope fields updated", { scopeKind: scopeKind || "none" });
}
function invalidateCampaignValidation(source: "input" | "scope" | "route"): void {
const status = document.querySelector<HTMLElement>("#backfillRequestValidation");
if (status) {
status.hidden = true;
status.textContent = "";
status.classList.remove("alert-success", "alert-danger");
status.classList.add("alert-secondary");
}
const summary = document.querySelector<HTMLElement>("#backfillRequestPreview");
if (summary) {
summary.hidden = true;
}
frontendTrace("main", "Backfill Desk campaign validation invalidated", { source });
}
function numericInputValue(inputId: string): number {
const input = document.querySelector<HTMLInputElement>(`#${inputId}`);
if (!input || !Number.isSafeInteger(input.valueAsNumber) || input.valueAsNumber < 0) {
return 0;
}
return input.valueAsNumber;
}
function rawOptionalInput(inputId: string, enabled: boolean): string | null {
if (!enabled) {
return null;
}
const input = document.querySelector<HTMLInputElement>(`#${inputId}`);
if (!input || input.value === "") {
return null;
}
return input.value;
}
function explicitSignatureLines(): string[] {
const textarea = document.querySelector<HTMLTextAreaElement>("#backfillExplicitSignatures");
if (!textarea || currentScopeKind() !== "explicit_signatures") {
return [];
}
return textarea.value.split(/\r?\n/).filter(line => line.length > 0);
}
function buildBackfillStartRequest(): BackfillStartRequestDto {
const scopeKind = currentScopeKind();
const role = document.querySelector<HTMLSelectElement>("#backfillHttpRoute")?.value ?? "";
const commitment = document.querySelector<HTMLSelectElement>("#backfillCommitment")?.value ?? "";
const addressScope = scopeKind === "latest_address" || scopeKind === "before_address" || scopeKind === "after_address";
const anchoredScope = scopeKind === "before_address" || scopeKind === "after_address";
const minContextSlotEnabled = scopeKind !== "explicit_signatures";
return {
address: rawOptionalInput("backfillAddress", addressScope),
anchorSignature: rawOptionalInput("backfillAnchorSignature", anchoredScope),
commitment,
explicitSignatures: explicitSignatureLines(),
hydrationConcurrency: numericInputValue("backfillHydrationConcurrency"),
httpRole: role,
maxCandidates: numericInputValue("backfillMaxCandidates"),
maxPages: numericInputValue("backfillMaxPages"),
minContextSlot: rawOptionalInput("backfillMinContextSlot", minContextSlotEnabled),
pageSize: numericInputValue("backfillPageSize"),
scopeKind,
};
}
function renderRequestPreview(preview: BackfillRequestPreviewDto): void {
const values: Record<string, string> = {
validatedNetwork: preview.network,
validatedHttpRole: preview.httpRole,
validatedCommitment: preview.commitment,
validatedScopeKind: preview.scopeKind,
validatedExplicitSignatureCount: preview.explicitSignatureCount.toString(),
validatedBounds: `${preview.pageSize} / ${preview.maxPages} / ${preview.maxCandidates} / ${preview.hydrationConcurrency}`,
validatedMinContextSlot: preview.minContextSlotPresent ? "présent" : "absent",
};
for (const [id, value] of Object.entries(values)) {
const element = document.querySelector<HTMLElement>(`#${id}`);
if (element) {
element.textContent = value;
}
}
const summary = document.querySelector<HTMLElement>("#backfillRequestPreview");
if (summary) {
summary.hidden = false;
}
const status = document.querySelector<HTMLElement>("#backfillRequestValidation");
if (status) {
status.hidden = false;
status.classList.remove("alert-secondary", "alert-danger");
status.classList.add("alert-success");
status.textContent = "Requête valide. Aucun job n'a été démarré.";
}
frontendTrace("main", "Backfill Desk validated request preview rendered", {
commitment: preview.commitment,
httpRole: preview.httpRole,
scopeKind: preview.scopeKind,
});
}
function renderRequestValidationError(caughtError: unknown): void {
const status = document.querySelector<HTMLElement>("#backfillRequestValidation");
if (!status) {
return;
}
let message = "Requête refusée par le backend.";
if (typeof caughtError === "object" && caughtError !== null) {
const candidate = caughtError as { code?: unknown; domain?: unknown; message?: unknown };
if (typeof candidate.domain === "string" && typeof candidate.code === "string" && typeof candidate.message === "string") {
message = `${candidate.domain}/${candidate.code}: ${candidate.message}`;
}
}
status.hidden = false;
status.classList.remove("alert-secondary", "alert-success");
status.classList.add("alert-danger");
status.textContent = message;
}
async function validateBackfillRequest(): Promise<void> {
const request = buildBackfillStartRequest();
frontendDebug("main", "Backfill Desk campaign validation requested", {
commitment: request.commitment,
httpRole: request.httpRole,
scopeKind: request.scopeKind,
});
try {
const preview = await invokeKsp<BackfillRequestPreviewDto>("main", "backfill_validate_request", { request });
renderRequestPreview(preview);
frontendDebug("main", "Backfill Desk campaign validation completed", {
commitment: preview.commitment,
httpRole: preview.httpRole,
scopeKind: preview.scopeKind,
});
} catch (caughtError) {
renderRequestValidationError(caughtError);
frontendWarn("main", "Backfill Desk campaign validation failed");
}
}
function bindCampaignForm(): void {
const scope = document.querySelector<HTMLSelectElement>("#backfillScopeKind");
if (scope) {
scope.addEventListener("change", () => {
frontendDebug("main", "Backfill Desk campaign scope changed", { scopeKind: scope.value || "none" });
updateCampaignScopeFields();
invalidateCampaignValidation("scope");
});
}
const form = document.querySelector<HTMLFormElement>("#backfillCampaignForm");
if (form) {
form.addEventListener("input", () => invalidateCampaignValidation("input"));
form.addEventListener("submit", event => {
event.preventDefault();
void validateBackfillRequest();
});
}
frontendTrace("main", "Backfill Desk campaign form handlers installed");
}
function routeLabel(route: BackfillHttpRouteOptionDto): string {
if (route.pooled) {
@@ -88,6 +342,7 @@ function routeLabel(route: BackfillHttpRouteOptionDto): string {
}
function renderBackfillOptions(options: BackfillDeskOptionsDto): void {
renderCampaignContract(options);
const select = document.querySelector<HTMLSelectElement>("#backfillHttpRoute");
if (select) {
const previous = select.value;
@@ -143,6 +398,7 @@ function bindBackfillRouteSelection(): void {
}
select.addEventListener("change", () => {
frontendDebug("main", "Backfill Desk HTTP route selection changed", { role: select.value || "none" });
invalidateCampaignValidation("route");
});
frontendTrace("main", "Backfill Desk HTTP route selection handler installed");
}
@@ -202,6 +458,7 @@ async function initializeMain(): Promise<void> {
bindNavigation();
bindRuntimeStatusRefresh();
bindBackfillRouteSelection();
bindCampaignForm();
activateView("backfill", "startup");
try {
await Promise.all([loadRuntimeStatus("startup"), loadBackfillOptions("startup")]);