301 lines
13 KiB
TypeScript
301 lines
13 KiB
TypeScript
// file: kb-app-demo-desktop/frontend/ts/demo_backfill.ts
|
||
// version: 6
|
||
|
||
import * as bootstrap from "bootstrap";
|
||
import "simplebar";
|
||
import "@andypf/json-viewer";
|
||
import ResizeObserver from "resize-observer-polyfill";
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import { listen } from "@tauri-apps/api/event";
|
||
import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log";
|
||
import type { DemoBackfillOptionsPayload } from "./bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillOptionsPayload.ts";
|
||
import type { DemoBackfillProgressPayload } from "./bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillProgressPayload.ts";
|
||
import type { DemoBackfillRequest } from "./bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillRequest.ts";
|
||
import type { DemoBackfillSummaryPayload } from "./bindings/kb_app_demo_desktop/demo_backfill/DemoBackfillSummaryPayload.ts";
|
||
|
||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||
|
||
const logLines: string[] = [];
|
||
const maximumLogLines = 1000;
|
||
let running = false;
|
||
|
||
function element<T extends HTMLElement>(selector: string): T {
|
||
const value = document.querySelector<T>(selector);
|
||
if (!value) {
|
||
throw new Error(`Missing UI element: ${selector}`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function inputValue(selector: string): string {
|
||
return element<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(selector).value.trim();
|
||
}
|
||
|
||
function integerValue(selector: string): number {
|
||
const parsed = Number.parseInt(inputValue(selector), 10);
|
||
if (!Number.isFinite(parsed)) {
|
||
throw new Error(`Valeur numérique invalide pour ${selector}`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||
|
||
function decodesToSolanaAddress(value: string): boolean {
|
||
if (value.length === 0) {
|
||
return false;
|
||
}
|
||
let numericValue = 0n;
|
||
for (const character of value) {
|
||
const digit = base58Alphabet.indexOf(character);
|
||
if (digit < 0) {
|
||
return false;
|
||
}
|
||
numericValue = numericValue * 58n + BigInt(digit);
|
||
}
|
||
let decodedNonZeroBytes = 0;
|
||
let remaining = numericValue;
|
||
while (remaining > 0n) {
|
||
decodedNonZeroBytes += 1;
|
||
remaining >>= 8n;
|
||
}
|
||
let leadingZeroBytes = 0;
|
||
for (const character of value) {
|
||
if (character !== "1") {
|
||
break;
|
||
}
|
||
leadingZeroBytes += 1;
|
||
}
|
||
return leadingZeroBytes + decodedNonZeroBytes === 32;
|
||
}
|
||
|
||
function appendLog(payload: DemoBackfillProgressPayload | { timestamp: string; level: string; message: string }): void {
|
||
const progress = "completed" in payload && payload.completed !== null && payload.total !== null
|
||
? ` [${payload.completed}/${payload.total}]`
|
||
: "";
|
||
logLines.push(`${payload.timestamp} ${payload.level.toUpperCase()}${progress} ${payload.message}`);
|
||
while (logLines.length > maximumLogLines) {
|
||
logLines.shift();
|
||
}
|
||
element<HTMLTextAreaElement>("#backfillLogOutput").value = logLines.join("\n");
|
||
element<HTMLTextAreaElement>("#backfillLogOutput").scrollTop = element<HTMLTextAreaElement>("#backfillLogOutput").scrollHeight;
|
||
}
|
||
|
||
function setRunning(value: boolean): void {
|
||
running = value;
|
||
document.querySelectorAll<HTMLButtonElement>(".backfill-start-button").forEach(button => {
|
||
button.disabled = value;
|
||
});
|
||
element<HTMLButtonElement>("#cancelBackfillButton").disabled = !value;
|
||
const badge = element<HTMLElement>("#backfillStatusBadge");
|
||
badge.textContent = value ? "Backfill en cours" : "Prêt";
|
||
badge.className = value ? "badge text-bg-warning" : "badge text-bg-success";
|
||
}
|
||
|
||
function commonRequest(mode: string): DemoBackfillRequest {
|
||
return {
|
||
role: inputValue("#backfillRoleSelect"),
|
||
commitment: inputValue("#backfillCommitmentSelect"),
|
||
mode,
|
||
signaturesText: null,
|
||
address: null,
|
||
anchorSignature: null,
|
||
direction: null,
|
||
limit: 1,
|
||
pageSize: integerValue("#backfillPageSizeInput"),
|
||
maxPages: integerValue("#backfillMaxPagesInput"),
|
||
maxConcurrentRequests: integerValue("#backfillConcurrencyInput"),
|
||
maxRetries: integerValue("#backfillRetriesInput"),
|
||
};
|
||
}
|
||
|
||
function buildRequest(mode: string): DemoBackfillRequest {
|
||
const request = commonRequest(mode);
|
||
if (mode === "explicit_signatures") {
|
||
request.signaturesText = element<HTMLTextAreaElement>("#explicitSignaturesTextarea").value;
|
||
return request;
|
||
}
|
||
request.address = inputValue(`#${mode}AddressInput`);
|
||
const anchorSignature = inputValue(`#${mode}AnchorInput`);
|
||
request.anchorSignature = anchorSignature.length > 0 ? anchorSignature : null;
|
||
request.direction = inputValue(`#${mode}DirectionSelect`);
|
||
request.limit = integerValue(`#${mode}LimitInput`);
|
||
return request;
|
||
}
|
||
|
||
function validationMessage(request: DemoBackfillRequest): string | null {
|
||
if (request.mode === "explicit_signatures") {
|
||
const signatureCount = request.signaturesText
|
||
? request.signaturesText.split(/\r?\n/).filter(value => value.trim().length > 0).length
|
||
: 0;
|
||
if (signatureCount === 0) {
|
||
return "Ajouter au moins une signature explicite avant de lancer le backfill.";
|
||
}
|
||
return null;
|
||
}
|
||
if (!request.address || request.address.trim().length === 0) {
|
||
return "L’adresse est obligatoire pour ce mode de backfill.";
|
||
}
|
||
if (!decodesToSolanaAddress(request.address.trim())) {
|
||
return "L’adresse Solana doit être une valeur Base58 décodant exactement sur 32 octets.";
|
||
}
|
||
if (request.direction === "after" && (!request.anchorSignature || request.anchorSignature.trim().length === 0)) {
|
||
return "La signature d’ancrage est obligatoire pour rechercher des transactions plus récentes.";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function reportValidationWarning(message: string): void {
|
||
renderBackfillSummary({ validation: message });
|
||
appendLog({ timestamp: new Date().toISOString(), level: "warn", message });
|
||
frontendDebug("kb-app-demo-desktop.frontend.backfill", `Backfill request rejected locally: ${message}`);
|
||
}
|
||
|
||
|
||
function synchronizeAnchorRequirement(mode: "program" | "token" | "pool"): void {
|
||
const direction = element<HTMLSelectElement>(`#${mode}DirectionSelect`).value;
|
||
const anchor = element<HTMLInputElement>(`#${mode}AnchorInput`);
|
||
const required = direction === "after";
|
||
anchor.required = required;
|
||
anchor.placeholder = required
|
||
? "Signature obligatoire pour rechercher après cette transaction"
|
||
: "Vide : commencer depuis les transactions les plus récentes";
|
||
}
|
||
|
||
async function executeBackfill(mode: string): Promise<void> {
|
||
if (running) {
|
||
return;
|
||
}
|
||
try {
|
||
const request = buildRequest(mode);
|
||
const invalid = validationMessage(request);
|
||
if (invalid) {
|
||
reportValidationWarning(invalid);
|
||
return;
|
||
}
|
||
setRunning(true);
|
||
renderBackfillSummary({ status: "Exécution en cours" });
|
||
appendLog({ timestamp: new Date().toISOString(), level: "info", message: `Démarrage du mode ${mode}` });
|
||
const summary = await invoke<DemoBackfillSummaryPayload>("demo_backfill_execute", { request });
|
||
renderBackfillSummary(summary);
|
||
appendLog({
|
||
timestamp: new Date().toISOString(),
|
||
level: summary.cancelled ? "warn" : "info",
|
||
message: `Campagne terminée : completed=${summary.candidatesCompleted}, cancelled=${summary.candidatesCancelled}, notStarted=${summary.candidatesNotStarted}, inserted=${summary.canonicalInserted}, existing=${summary.existingSkipped}, missing=${summary.missing}, failed=${summary.failed}`,
|
||
});
|
||
} catch (caughtError) {
|
||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||
renderBackfillSummary({ error: message });
|
||
appendLog({ timestamp: new Date().toISOString(), level: "error", message });
|
||
frontendError("kb-app-demo-desktop.frontend.backfill", `Backfill failed: ${message}`);
|
||
} finally {
|
||
setRunning(false);
|
||
}
|
||
}
|
||
|
||
async function cancelBackfill(): Promise<void> {
|
||
try {
|
||
const accepted = await invoke<boolean>("demo_backfill_cancel");
|
||
appendLog({
|
||
timestamp: new Date().toISOString(),
|
||
level: accepted ? "warn" : "info",
|
||
message: accepted ? "Demande d'arrêt envoyée." : "Aucune campagne active.",
|
||
});
|
||
} catch (caughtError) {
|
||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||
frontendError("kb-app-demo-desktop.frontend.backfill", `Cancellation failed: ${message}`);
|
||
}
|
||
}
|
||
|
||
async function loadOptions(): Promise<void> {
|
||
const options = await invoke<DemoBackfillOptionsPayload>("demo_backfill_options");
|
||
const roleSelect = element<HTMLSelectElement>("#backfillRoleSelect");
|
||
roleSelect.replaceChildren();
|
||
for (const role of options.roles) {
|
||
const option = document.createElement("option");
|
||
option.value = role.role;
|
||
option.textContent = `${role.role} — ${role.providers.join(", ")}`;
|
||
option.selected = role.role === options.defaultRole;
|
||
roleSelect.append(option);
|
||
}
|
||
if (options.roles.length === 0) {
|
||
const option = document.createElement("option");
|
||
option.value = "";
|
||
option.textContent = "Aucun rôle compatible";
|
||
roleSelect.append(option);
|
||
}
|
||
element<HTMLSelectElement>("#backfillCommitmentSelect").value = options.defaultCommitment;
|
||
element<HTMLInputElement>("#backfillPageSizeInput").value = String(options.defaultPageSize);
|
||
element<HTMLInputElement>("#backfillMaxPagesInput").value = String(options.defaultMaxPages);
|
||
element<HTMLInputElement>("#backfillConcurrencyInput").value = String(options.defaultMaxConcurrentRequests);
|
||
element<HTMLInputElement>("#backfillRetriesInput").value = String(options.defaultMaxRetries);
|
||
setRunning(options.running);
|
||
}
|
||
|
||
|
||
function renderBackfillSummary(value: unknown): void {
|
||
const container = document.querySelector<HTMLElement>("#backfillSummaryOutput");
|
||
if (!container) {
|
||
return;
|
||
}
|
||
container.textContent = "";
|
||
const viewer = document.createElement("andypf-json-viewer");
|
||
viewer.setAttribute("indent", "2");
|
||
viewer.setAttribute("expanded", "1");
|
||
viewer.setAttribute("theme", "default-light");
|
||
viewer.setAttribute("show-data-types", "true");
|
||
viewer.setAttribute("show-toolbar", "false");
|
||
viewer.setAttribute("show-copy", "true");
|
||
viewer.setAttribute("data", JSON.stringify(value ?? { status: "Aucun résultat" }, null, 2));
|
||
container.appendChild(viewer);
|
||
}
|
||
|
||
async function copyText(value: string): Promise<void> {
|
||
await navigator.clipboard.writeText(value);
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
installFrontendConsoleBridge("kb-app-demo-desktop.frontend.backfill");
|
||
frontendDebug("kb-app-demo-desktop.frontend.backfill", "backfill demo window loaded");
|
||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(item => new bootstrap.Tooltip(item));
|
||
for (const mode of ["program", "token", "pool"] as const) {
|
||
const direction = element<HTMLSelectElement>(`#${mode}DirectionSelect`);
|
||
direction.addEventListener("change", () => synchronizeAnchorRequirement(mode));
|
||
synchronizeAnchorRequirement(mode);
|
||
}
|
||
document.querySelectorAll<HTMLButtonElement>(".backfill-start-button").forEach(button => {
|
||
button.addEventListener("click", () => {
|
||
const mode = button.dataset.backfillMode;
|
||
if (mode) {
|
||
void executeBackfill(mode);
|
||
}
|
||
});
|
||
});
|
||
element<HTMLButtonElement>("#cancelBackfillButton").addEventListener("click", () => {
|
||
void cancelBackfill();
|
||
});
|
||
element<HTMLButtonElement>("#clearBackfillLogButton").addEventListener("click", () => {
|
||
logLines.length = 0;
|
||
element<HTMLTextAreaElement>("#backfillLogOutput").value = "";
|
||
});
|
||
void listen<DemoBackfillProgressPayload>("demo-backfill-progress", event => {
|
||
appendLog(event.payload);
|
||
});
|
||
void loadOptions().catch(caughtError => {
|
||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||
appendLog({ timestamp: new Date().toISOString(), level: "error", message });
|
||
frontendError("kb-app-demo-desktop.frontend.backfill", `Options loading failed: ${message}`);
|
||
});
|
||
|
||
document.querySelector<HTMLButtonElement>("#copyBackfillLogButton")?.addEventListener("click", async () => {
|
||
const value = document.querySelector<HTMLTextAreaElement>("#backfillLogOutput")?.value ?? "";
|
||
await copyText(value);
|
||
});
|
||
document.querySelector<HTMLButtonElement>("#copyBackfillSummaryButton")?.addEventListener("click", async () => {
|
||
const value = document.querySelector("#backfillSummaryOutput andypf-json-viewer")?.getAttribute("data") ?? "";
|
||
await copyText(value);
|
||
});
|
||
renderBackfillSummary(null);
|
||
});
|