801 lines
34 KiB
TypeScript
801 lines
34 KiB
TypeScript
// file: kb-app-demo-desktop/frontend/ts/demo_store_replay_candidates.ts
|
||
// version: 12
|
||
|
||
import * as bootstrap from "bootstrap";
|
||
import "simplebar";
|
||
import ResizeObserver from "resize-observer-polyfill";
|
||
import DataTable, { type Api } from "datatables.net-bs5";
|
||
import "datatables.net-bs5/css/dataTables.bootstrap5.css";
|
||
import "datatables.net-select-bs5";
|
||
import "datatables.net-select-bs5/css/select.bootstrap5.css";
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log";
|
||
import type { DemoStoreReplayEntityRequest } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayEntityRequest";
|
||
import type { DemoStoreReplayEntityRow } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayEntityRow";
|
||
import type { DemoStoreReplayKnownProgramOption } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayKnownProgramOption";
|
||
import type { DemoStoreReplayOptionsPayload } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayOptionsPayload";
|
||
import type { DemoStoreReplayProgramRequest } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayProgramRequest";
|
||
import type { DemoStoreReplayProgramRow } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayProgramRow";
|
||
import type { DemoStoreReplayTransactionRequest } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayTransactionRequest";
|
||
import type { DemoStoreReplayTransactionRow } from "./bindings/kb_app_demo_desktop/demo_store_replay_candidates/DemoStoreReplayTransactionRow";
|
||
|
||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||
|
||
type EntityKindCode = "mint" | "owner" | "account_key";
|
||
|
||
type EntityViewConfig = {
|
||
kind: EntityKindCode;
|
||
label: string;
|
||
pluralLabel: string;
|
||
tableSelector: string;
|
||
containsSelector: string;
|
||
limitSelector: string;
|
||
summarySelector: string;
|
||
loadButtonSelector: string;
|
||
resetButtonSelector: string;
|
||
useButtonSelector: string;
|
||
copyButtonSelector: string;
|
||
exportButtonSelector: string;
|
||
fileName: string;
|
||
};
|
||
|
||
const tracingTarget = "kb_app_demo_desktop.frontend.demo_store_replay_candidates";
|
||
const entityViewConfigs: EntityViewConfig[] = [
|
||
{
|
||
kind: "mint",
|
||
label: "mint",
|
||
pluralLabel: "mints",
|
||
tableSelector: "#mintCandidateTable",
|
||
containsSelector: "#mintContainsInput",
|
||
limitSelector: "#mintLimitInput",
|
||
summarySelector: "#mintResultSummary",
|
||
loadButtonSelector: "#loadMintsButton",
|
||
resetButtonSelector: "#resetMintsButton",
|
||
useButtonSelector: "#useSelectedMintsButton",
|
||
copyButtonSelector: "#copyMintsButton",
|
||
exportButtonSelector: "#exportMintsCsvButton",
|
||
fileName: "replay_mints.csv",
|
||
},
|
||
{
|
||
kind: "owner",
|
||
label: "owner",
|
||
pluralLabel: "owners",
|
||
tableSelector: "#ownerCandidateTable",
|
||
containsSelector: "#ownerContainsInput",
|
||
limitSelector: "#ownerLimitInput",
|
||
summarySelector: "#ownerResultSummary",
|
||
loadButtonSelector: "#loadOwnersButton",
|
||
resetButtonSelector: "#resetOwnersButton",
|
||
useButtonSelector: "#useSelectedOwnersButton",
|
||
copyButtonSelector: "#copyOwnersButton",
|
||
exportButtonSelector: "#exportOwnersCsvButton",
|
||
fileName: "replay_owners.csv",
|
||
},
|
||
{
|
||
kind: "account_key",
|
||
label: "compte",
|
||
pluralLabel: "comptes",
|
||
tableSelector: "#accountCandidateTable",
|
||
containsSelector: "#accountContainsInput",
|
||
limitSelector: "#accountLimitInput",
|
||
summarySelector: "#accountResultSummary",
|
||
loadButtonSelector: "#loadAccountsButton",
|
||
resetButtonSelector: "#resetAccountsButton",
|
||
useButtonSelector: "#useSelectedAccountsButton",
|
||
copyButtonSelector: "#copyAccountsButton",
|
||
exportButtonSelector: "#exportAccountsCsvButton",
|
||
fileName: "replay_account_keys.csv",
|
||
},
|
||
];
|
||
let transactionTable: Api<DemoStoreReplayTransactionRow> | null = null;
|
||
let programTable: Api<DemoStoreReplayProgramRow> | null = null;
|
||
let mintTable: Api<DemoStoreReplayEntityRow> | null = null;
|
||
let ownerTable: Api<DemoStoreReplayEntityRow> | null = null;
|
||
let accountTable: Api<DemoStoreReplayEntityRow> | null = null;
|
||
let busyOperationCount = 0;
|
||
let maximumReplayCandidateLimit = 500;
|
||
|
||
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 optionalInputValue(selector: string): string | null {
|
||
const value = element<HTMLInputElement | HTMLSelectElement>(selector).value.trim();
|
||
return value.length === 0 ? null : value;
|
||
}
|
||
|
||
function requiredInputValue(selector: string): string {
|
||
const value = optionalInputValue(selector);
|
||
if (value === null) {
|
||
throw new Error(`Valeur obligatoire manquante pour ${selector}`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function positiveIntegerValue(selector: string): number {
|
||
const value = Number.parseInt(requiredInputValue(selector), 10);
|
||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||
throw new Error(`Valeur entière positive invalide pour ${selector}`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function boundedPositiveIntegerValue(selector: string): number {
|
||
const value = positiveIntegerValue(selector);
|
||
if (value > maximumReplayCandidateLimit) {
|
||
throw new Error(
|
||
`La limite doit être comprise entre 1 et ${maximumReplayCandidateLimit} pour ${selector}`,
|
||
);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function optionalNonNegativeIntegerValue(selector: string): number | null {
|
||
const raw = optionalInputValue(selector);
|
||
if (raw === null) {
|
||
return null;
|
||
}
|
||
const value = Number.parseInt(raw, 10);
|
||
if (!Number.isSafeInteger(value) || value < 0) {
|
||
throw new Error(`Valeur entière positive ou nulle invalide pour ${selector}`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function escapeHtml(value: unknown): string {
|
||
const text = String(value ?? "");
|
||
return text
|
||
.split("&").join("&")
|
||
.split("<").join("<")
|
||
.split(">").join(">")
|
||
.split('"').join(""")
|
||
.split("'").join("'");
|
||
}
|
||
|
||
function codeDisplay(value: unknown, type: string): string {
|
||
if (type !== "display") {
|
||
return String(value ?? "");
|
||
}
|
||
return `<code class="text-break">${escapeHtml(value)}</code>`;
|
||
}
|
||
|
||
function compactIdentifier(value: string, leadingLength: number = 12, trailingLength: number = 10): string {
|
||
if (value.length <= leadingLength + trailingLength + 1) {
|
||
return value;
|
||
}
|
||
return `${value.slice(0, leadingLength)}…${value.slice(-trailingLength)}`;
|
||
}
|
||
|
||
function copyableCodeDisplay(label: string): (value: unknown, type: string) => string {
|
||
return (value: unknown, type: string): string => {
|
||
const text = String(value ?? "");
|
||
if (type !== "display") {
|
||
return text;
|
||
}
|
||
const escapedText = escapeHtml(text);
|
||
const compactText = escapeHtml(compactIdentifier(text));
|
||
const escapedLabel = escapeHtml(label);
|
||
return `<span class="d-inline-flex align-items-center gap-1 text-nowrap"><code data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedText}">${compactText}</code><button class="btn btn-sm btn-link text-secondary p-0 replay-cell-copy" type="button" data-bs-toggle="tooltip" data-bs-placement="top" title="Copier ${escapedLabel}" aria-label="Copier ${escapedLabel}" data-copy-value="${escapedText}" data-copy-label="${escapedLabel}"><i class="fa-regular fa-copy" aria-hidden="true"></i></button></span>`;
|
||
};
|
||
}
|
||
|
||
function badgeDisplay(value: unknown, type: string): string {
|
||
if (type !== "display") {
|
||
return String(value ?? "");
|
||
}
|
||
const text = String(value ?? "—");
|
||
const className = text === "succeeded" || text === "core_extracted"
|
||
? "text-bg-success"
|
||
: text === "failed"
|
||
? "text-bg-danger"
|
||
: text === "running" || text === "received"
|
||
? "text-bg-warning"
|
||
: "text-bg-secondary";
|
||
return `<span class="badge ${className}">${escapeHtml(text)}</span>`;
|
||
}
|
||
|
||
function coreStatusText(row: DemoStoreReplayTransactionRow): string {
|
||
if (!row.hasCoreTransaction) {
|
||
return "absent";
|
||
}
|
||
if (row.transactionFailed === true) {
|
||
return "échec on-chain";
|
||
}
|
||
return "présent";
|
||
}
|
||
|
||
function coreDisplay(_value: unknown, type: string, row: DemoStoreReplayTransactionRow): string | number {
|
||
if (type === "display") {
|
||
if (!row.hasCoreTransaction) {
|
||
return '<span class="badge text-bg-secondary">absent</span>';
|
||
}
|
||
if (row.transactionFailed === true) {
|
||
return '<span class="badge text-bg-danger" title="La transaction Solana a échoué on-chain, mais son graphe core a été extrait.">échec on-chain</span>';
|
||
}
|
||
return '<span class="badge text-bg-success">présent</span>';
|
||
}
|
||
if (type === "filter") {
|
||
return coreStatusText(row);
|
||
}
|
||
if (row.transactionFailed === true) {
|
||
return 0;
|
||
}
|
||
return row.hasCoreTransaction ? 1 : 2;
|
||
}
|
||
|
||
function occurrenceRatioDisplay(_value: unknown, type: string, row: DemoStoreReplayEntityRow): string | number {
|
||
const ratio = row.transactionCount > 0 ? row.occurrenceCount / row.transactionCount : 0;
|
||
if (type === "display" || type === "filter") {
|
||
return ratio.toFixed(2);
|
||
}
|
||
return ratio;
|
||
}
|
||
|
||
function slotSpanDisplay(_value: unknown, type: string, row: DemoStoreReplayEntityRow): string | number {
|
||
const span = Math.max(0, row.maxSlot - row.minSlot);
|
||
if (type === "display" || type === "filter") {
|
||
return String(span);
|
||
}
|
||
return span;
|
||
}
|
||
|
||
function commonLanguage(emptyTable: string, infoLabel: string, zeroRecords: string): object {
|
||
return {
|
||
emptyTable,
|
||
info: `_START_ à _END_ sur _TOTAL_ ${infoLabel}`,
|
||
infoEmpty: `0 ${infoLabel}`,
|
||
infoFiltered: "(filtré depuis _MAX_)",
|
||
lengthMenu: "Afficher _MENU_",
|
||
search: "Filtre local :",
|
||
zeroRecords,
|
||
paginate: { first: "Premier", last: "Dernier", next: "Suivant", previous: "Précédent" },
|
||
select: { rows: { _: "%d lignes sélectionnées", 0: "", 1: "1 ligne sélectionnée" } },
|
||
};
|
||
}
|
||
|
||
function createTransactionTable(): Api<DemoStoreReplayTransactionRow> {
|
||
return new DataTable<DemoStoreReplayTransactionRow>("#transactionCandidateTable", {
|
||
data: [],
|
||
columns: [
|
||
{
|
||
data: null,
|
||
className: "replay-select-column text-center",
|
||
width: "1%",
|
||
orderable: false,
|
||
searchable: false,
|
||
render: DataTable.render.select(),
|
||
},
|
||
{ data: "signature", render: copyableCodeDisplay("la signature") },
|
||
{ data: "slot" },
|
||
{ data: "rawProcessingState", render: badgeDisplay },
|
||
{ data: "hasCoreTransaction", render: coreDisplay },
|
||
{ data: "ledgerStatus", render: badgeDisplay },
|
||
{ data: "processorVersion", defaultContent: "—", render: codeDisplay },
|
||
{ data: "attemptCount" },
|
||
{ data: "topLevelInstructionCount" },
|
||
{ data: "innerInstructionCount" },
|
||
{ data: "topLevelProgramCount" },
|
||
{ data: "innerProgramCount" },
|
||
{ data: "updatedAt" },
|
||
],
|
||
order: [[2, "desc"]],
|
||
pageLength: 10,
|
||
lengthMenu: [10, 25, 50, 100, 250],
|
||
select: { style: "multi", selector: "td:first-child", headerCheckbox: "select-all" },
|
||
language: commonLanguage(
|
||
"Aucune transaction chargée",
|
||
"transactions",
|
||
"Aucune transaction ne correspond au filtre local",
|
||
),
|
||
drawCallback: () => initializeTooltips(),
|
||
});
|
||
}
|
||
|
||
function createProgramTable(): Api<DemoStoreReplayProgramRow> {
|
||
return new DataTable<DemoStoreReplayProgramRow>("#programCandidateTable", {
|
||
data: [],
|
||
columns: [
|
||
{
|
||
data: null,
|
||
className: "replay-select-column text-center",
|
||
width: "1%",
|
||
orderable: false,
|
||
searchable: false,
|
||
render: DataTable.render.select(),
|
||
},
|
||
{ data: "programCode", defaultContent: "—", render: codeDisplay },
|
||
{ data: "programId", render: copyableCodeDisplay("le program ID") },
|
||
{ data: "transactionCount" },
|
||
{ data: "topLevelInstructionCount" },
|
||
{ data: "innerInstructionCount" },
|
||
{ data: "logCount" },
|
||
{ data: "minSlot" },
|
||
{ data: "maxSlot" },
|
||
],
|
||
order: [[3, "desc"]],
|
||
pageLength: 10,
|
||
lengthMenu: [10, 25, 50, 100, 250],
|
||
select: { style: "multi", selector: "td:first-child", headerCheckbox: "select-all" },
|
||
language: commonLanguage(
|
||
"Aucun programme chargé",
|
||
"programmes",
|
||
"Aucun programme ne correspond au filtre local",
|
||
),
|
||
drawCallback: () => initializeTooltips(),
|
||
});
|
||
}
|
||
|
||
function createEntityTable(config: EntityViewConfig): Api<DemoStoreReplayEntityRow> {
|
||
return new DataTable<DemoStoreReplayEntityRow>(config.tableSelector, {
|
||
data: [],
|
||
columns: [
|
||
{
|
||
data: null,
|
||
className: "replay-select-column text-center",
|
||
width: "1%",
|
||
orderable: false,
|
||
searchable: false,
|
||
render: DataTable.render.select(),
|
||
},
|
||
{ data: "entityValue", render: copyableCodeDisplay(config.label) },
|
||
{ data: "transactionCount" },
|
||
{ data: "occurrenceCount" },
|
||
{ data: null, render: occurrenceRatioDisplay },
|
||
{ data: "minSlot" },
|
||
{ data: "maxSlot" },
|
||
{ data: null, render: slotSpanDisplay },
|
||
],
|
||
order: [[2, "desc"]],
|
||
pageLength: 10,
|
||
lengthMenu: [10, 25, 50, 100, 250],
|
||
select: { style: "multi", selector: "td:first-child", headerCheckbox: "select-all" },
|
||
language: commonLanguage(
|
||
`Aucun ${config.label} chargé`,
|
||
config.pluralLabel,
|
||
`Aucun ${config.label} ne correspond au filtre local`,
|
||
),
|
||
drawCallback: () => initializeTooltips(),
|
||
});
|
||
}
|
||
|
||
function entityTable(kind: EntityKindCode): Api<DemoStoreReplayEntityRow> | null {
|
||
if (kind === "mint") {
|
||
return mintTable;
|
||
}
|
||
if (kind === "owner") {
|
||
return ownerTable;
|
||
}
|
||
return accountTable;
|
||
}
|
||
|
||
function replaceRows<T>(table: Api<T>, rows: T[]): void {
|
||
table.clear();
|
||
table.rows.add(rows);
|
||
table.draw();
|
||
}
|
||
|
||
function resetTableState<T>(table: Api<T> | null): void {
|
||
if (table === null) {
|
||
return;
|
||
}
|
||
table.search("");
|
||
table.columns().search("");
|
||
table.rows().deselect();
|
||
table.draw();
|
||
}
|
||
|
||
function selectedOrFilteredRows<T>(table: Api<T>): T[] {
|
||
const selected = table.rows({ selected: true, search: "applied" }).data().toArray();
|
||
if (selected.length > 0) {
|
||
return selected;
|
||
}
|
||
return table.rows({ search: "applied" }).data().toArray();
|
||
}
|
||
|
||
function selectedOrSingleFilteredRow<T>(table: Api<T>, label: string): T {
|
||
const selected = table.rows({ selected: true }).data().toArray();
|
||
if (selected.length === 1) {
|
||
return selected[0];
|
||
}
|
||
if (selected.length > 1) {
|
||
throw new Error(`Plusieurs ${label} sont cochés. Conservez une seule sélection.`);
|
||
}
|
||
const filtered = table.rows({ search: "applied" }).data().toArray();
|
||
if (filtered.length === 1) {
|
||
return filtered[0];
|
||
}
|
||
throw new Error(`Cochez exactement un ${label}, ou réduisez le filtre local à une seule ligne.`);
|
||
}
|
||
|
||
function setBusy(busy: boolean, message: string): void {
|
||
busyOperationCount = busy ? busyOperationCount + 1 : Math.max(0, busyOperationCount - 1);
|
||
const operationInProgress = busyOperationCount > 0;
|
||
const badge = element<HTMLElement>("#replayCandidateStatusBadge");
|
||
badge.textContent = operationInProgress ? (busy ? message : "Chargement en cours") : "Prêt";
|
||
badge.className = operationInProgress ? "badge text-bg-warning" : "badge text-bg-success";
|
||
document.querySelectorAll<HTMLButtonElement>("button").forEach(button => {
|
||
if (button.id !== "openCoreExtractionButton") {
|
||
button.disabled = operationInProgress;
|
||
}
|
||
});
|
||
}
|
||
|
||
function buildTransactionRequest(): DemoStoreReplayTransactionRequest {
|
||
const entityKind = optionalInputValue("#transactionEntityKindSelect");
|
||
const entityValue = optionalInputValue("#transactionEntityValueInput");
|
||
if ((entityKind === null) !== (entityValue === null)) {
|
||
throw new Error("Le type d’entité et sa valeur doivent être renseignés ensemble.");
|
||
}
|
||
return {
|
||
signatureContains: optionalInputValue("#transactionSignatureContainsInput"),
|
||
minSlot: optionalNonNegativeIntegerValue("#transactionMinSlotInput"),
|
||
maxSlot: optionalNonNegativeIntegerValue("#transactionMaxSlotInput"),
|
||
rawProcessingState: optionalInputValue("#transactionRawStateSelect"),
|
||
ledgerStatus: optionalInputValue("#transactionLedgerStatusSelect"),
|
||
programId: optionalInputValue("#transactionProgramIdInput"),
|
||
programScope: requiredInputValue("#transactionProgramScopeSelect"),
|
||
entityKind,
|
||
entityValue,
|
||
limit: boundedPositiveIntegerValue("#transactionLimitInput"),
|
||
newestFirst: element<HTMLInputElement>("#transactionNewestFirstInput").checked,
|
||
};
|
||
}
|
||
|
||
async function resetTransactionFilters(): Promise<void> {
|
||
element<HTMLInputElement>("#transactionSignatureContainsInput").value = "";
|
||
element<HTMLInputElement>("#transactionMinSlotInput").value = "";
|
||
element<HTMLInputElement>("#transactionMaxSlotInput").value = "";
|
||
element<HTMLSelectElement>("#transactionRawStateSelect").value = "";
|
||
element<HTMLSelectElement>("#transactionLedgerStatusSelect").value = "";
|
||
element<HTMLInputElement>("#transactionProgramIdInput").value = "";
|
||
element<HTMLSelectElement>("#transactionProgramScopeSelect").value = "any";
|
||
element<HTMLSelectElement>("#transactionEntityKindSelect").value = "";
|
||
element<HTMLInputElement>("#transactionEntityValueInput").value = "";
|
||
element<HTMLInputElement>("#transactionLimitInput").value = "500";
|
||
element<HTMLInputElement>("#transactionNewestFirstInput").checked = true;
|
||
resetTableState(transactionTable);
|
||
await loadTransactions();
|
||
}
|
||
|
||
async function resetProgramFilters(): Promise<void> {
|
||
element<HTMLSelectElement>("#knownProgramSelect").value = "";
|
||
element<HTMLInputElement>("#programContainsInput").value = "";
|
||
element<HTMLInputElement>("#programLimitInput").value = "500";
|
||
resetTableState(programTable);
|
||
await loadPrograms();
|
||
}
|
||
|
||
async function resetEntityFilters(config: EntityViewConfig): Promise<void> {
|
||
element<HTMLInputElement>(config.containsSelector).value = "";
|
||
element<HTMLInputElement>(config.limitSelector).value = "500";
|
||
resetTableState(entityTable(config.kind));
|
||
await loadEntity(config);
|
||
}
|
||
|
||
async function loadTransactions(): Promise<void> {
|
||
if (transactionTable === null) {
|
||
return;
|
||
}
|
||
try {
|
||
setBusy(true, "Chargement transactions");
|
||
const request = buildTransactionRequest();
|
||
const rows = await invoke<DemoStoreReplayTransactionRow[]>("load_demo_store_replay_transactions", { request });
|
||
replaceRows(transactionTable, rows);
|
||
element<HTMLElement>("#transactionResultSummary").textContent = `${rows.length} transaction(s) chargée(s) depuis le store actif.`;
|
||
frontendDebug(tracingTarget, `loaded ${rows.length} replay transaction candidates`);
|
||
} catch (caughtError) {
|
||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||
element<HTMLElement>("#transactionResultSummary").textContent = `Erreur : ${message}`;
|
||
frontendError(tracingTarget, `transaction candidate loading failed: ${message}`);
|
||
} finally {
|
||
setBusy(false, "Prêt");
|
||
}
|
||
}
|
||
|
||
async function loadPrograms(): Promise<void> {
|
||
if (programTable === null) {
|
||
return;
|
||
}
|
||
try {
|
||
setBusy(true, "Chargement programmes");
|
||
const request: DemoStoreReplayProgramRequest = {
|
||
programIdContains: optionalInputValue("#programContainsInput"),
|
||
limit: boundedPositiveIntegerValue("#programLimitInput"),
|
||
};
|
||
const rows = await invoke<DemoStoreReplayProgramRow[]>("load_demo_store_replay_programs", { request });
|
||
replaceRows(programTable, rows);
|
||
element<HTMLElement>("#programResultSummary").textContent = `${rows.length} programme(s) chargé(s), avec occurrences top-level, inner et logs liés.`;
|
||
frontendDebug(tracingTarget, `loaded ${rows.length} replay program summaries`);
|
||
} catch (caughtError) {
|
||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||
element<HTMLElement>("#programResultSummary").textContent = `Erreur : ${message}`;
|
||
frontendError(tracingTarget, `program summary loading failed: ${message}`);
|
||
} finally {
|
||
setBusy(false, "Prêt");
|
||
}
|
||
}
|
||
|
||
async function loadEntity(config: EntityViewConfig): Promise<void> {
|
||
const table = entityTable(config.kind);
|
||
if (table === null) {
|
||
return;
|
||
}
|
||
try {
|
||
setBusy(true, `Chargement ${config.pluralLabel}`);
|
||
const request: DemoStoreReplayEntityRequest = {
|
||
entityKind: config.kind,
|
||
entityValueContains: optionalInputValue(config.containsSelector),
|
||
limit: boundedPositiveIntegerValue(config.limitSelector),
|
||
};
|
||
const rows = await invoke<DemoStoreReplayEntityRow[]>("load_demo_store_replay_entities", { request });
|
||
replaceRows(table, rows);
|
||
element<HTMLElement>(config.summarySelector).textContent = `${rows.length} ${config.pluralLabel} chargé(s) depuis le store actif.`;
|
||
frontendDebug(tracingTarget, `loaded ${rows.length} replay ${config.kind} summaries`);
|
||
} catch (caughtError) {
|
||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||
element<HTMLElement>(config.summarySelector).textContent = `Erreur : ${message}`;
|
||
frontendError(tracingTarget, `${config.kind} summary loading failed: ${message}`);
|
||
} finally {
|
||
setBusy(false, "Prêt");
|
||
}
|
||
}
|
||
|
||
async function copyLines(lines: string[], label: string): Promise<void> {
|
||
if (lines.length === 0) {
|
||
throw new Error(`Aucune ${label} à copier.`);
|
||
}
|
||
await navigator.clipboard.writeText(lines.join("\n"));
|
||
element<HTMLElement>("#replayCandidateStatusBadge").textContent = `${lines.length} ${label} copiée(s)`;
|
||
}
|
||
|
||
function csvCell(value: unknown): string {
|
||
const text = value === null || value === undefined ? "" : String(value);
|
||
return `"${text.split('"').join('""')}"`;
|
||
}
|
||
|
||
function csvContent(headers: string[], rows: unknown[][]): string {
|
||
const lines = [headers, ...rows].map(row => row.map(csvCell).join(";"));
|
||
return `${lines.join("\r\n")}\r\n`;
|
||
}
|
||
|
||
async function saveCsv(fileName: string, content: string, rowCount: number): Promise<void> {
|
||
if (rowCount === 0) {
|
||
throw new Error("Aucune ligne à exporter.");
|
||
}
|
||
const path = await invoke<string>("export_demo_store_replay_csv", { fileName, content });
|
||
const badge = element<HTMLElement>("#replayCandidateStatusBadge");
|
||
badge.textContent = `${rowCount} ligne(s) exportée(s) : ${path}`;
|
||
badge.className = "badge text-bg-success";
|
||
frontendDebug(tracingTarget, `CSV export written: ${path}`);
|
||
}
|
||
|
||
async function exportTransactionsCsv(): Promise<void> {
|
||
const rows = transactionRowsForExport();
|
||
const content = csvContent(
|
||
["signature", "slot", "raw_processing_state", "retention_state", "core_status", "ledger_status", "processor_version", "attempt_count", "top_level_instruction_count", "inner_instruction_count", "top_level_program_count", "inner_program_count", "updated_at"],
|
||
rows.map(row => [row.signature, row.slot, row.rawProcessingState, row.retentionState, coreStatusText(row), row.ledgerStatus, row.processorVersion, row.attemptCount, row.topLevelInstructionCount, row.innerInstructionCount, row.topLevelProgramCount, row.innerProgramCount, row.updatedAt]),
|
||
);
|
||
await saveCsv("replay_transactions.csv", content, rows.length);
|
||
}
|
||
|
||
async function exportProgramsCsv(): Promise<void> {
|
||
const rows = programRowsForExport();
|
||
const content = csvContent(
|
||
["program_code", "program_id", "transaction_count", "top_level_instruction_count", "inner_instruction_count", "log_count", "min_slot", "max_slot"],
|
||
rows.map(row => [row.programCode, row.programId, row.transactionCount, row.topLevelInstructionCount, row.innerInstructionCount, row.logCount, row.minSlot, row.maxSlot]),
|
||
);
|
||
await saveCsv("replay_programs.csv", content, rows.length);
|
||
}
|
||
|
||
async function exportEntityCsv(config: EntityViewConfig): Promise<void> {
|
||
const rows = entityRowsForExport(config.kind);
|
||
const content = csvContent(
|
||
["entity_kind", "entity_value", "transaction_count", "occurrence_count", "occurrences_per_transaction", "min_slot", "max_slot", "slot_span"],
|
||
rows.map(row => [row.entityKind, row.entityValue, row.transactionCount, row.occurrenceCount, row.transactionCount > 0 ? row.occurrenceCount / row.transactionCount : 0, row.minSlot, row.maxSlot, Math.max(0, row.maxSlot - row.minSlot)]),
|
||
);
|
||
await saveCsv(config.fileName, content, rows.length);
|
||
}
|
||
|
||
function transactionRowsForExport(): DemoStoreReplayTransactionRow[] {
|
||
if (transactionTable === null) {
|
||
return [];
|
||
}
|
||
return selectedOrFilteredRows(transactionTable);
|
||
}
|
||
|
||
function programRowsForExport(): DemoStoreReplayProgramRow[] {
|
||
if (programTable === null) {
|
||
return [];
|
||
}
|
||
return selectedOrFilteredRows(programTable);
|
||
}
|
||
|
||
function entityRowsForExport(kind: EntityKindCode): DemoStoreReplayEntityRow[] {
|
||
const table = entityTable(kind);
|
||
if (table === null) {
|
||
return [];
|
||
}
|
||
return selectedOrFilteredRows(table);
|
||
}
|
||
|
||
function showTransactionsTab(): void {
|
||
bootstrap.Tab.getOrCreateInstance(element<HTMLButtonElement>("#transactionsTabButton")).show();
|
||
}
|
||
|
||
async function useSelectedProgram(): Promise<void> {
|
||
if (programTable === null) {
|
||
return;
|
||
}
|
||
const row = selectedOrSingleFilteredRow(programTable, "programme");
|
||
element<HTMLInputElement>("#transactionProgramIdInput").value = row.programId;
|
||
element<HTMLSelectElement>("#transactionProgramScopeSelect").value = "any";
|
||
showTransactionsTab();
|
||
await loadTransactions();
|
||
}
|
||
|
||
async function useSelectedEntity(config: EntityViewConfig): Promise<void> {
|
||
const table = entityTable(config.kind);
|
||
if (table === null) {
|
||
return;
|
||
}
|
||
const row = selectedOrSingleFilteredRow(table, config.label);
|
||
element<HTMLSelectElement>("#transactionEntityKindSelect").value = config.kind;
|
||
element<HTMLInputElement>("#transactionEntityValueInput").value = row.entityValue;
|
||
showTransactionsTab();
|
||
await loadTransactions();
|
||
}
|
||
|
||
async function openCoreExtraction(): Promise<void> {
|
||
try {
|
||
await invoke("open_demo_core_extraction_window");
|
||
} catch (caughtError) {
|
||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||
frontendError(tracingTarget, `core extraction window opening failed: ${message}`);
|
||
}
|
||
}
|
||
|
||
async function loadOptions(): Promise<void> {
|
||
const options = await invoke<DemoStoreReplayOptionsPayload>("demo_store_replay_options");
|
||
if (!Number.isSafeInteger(options.maximumLimit) || options.maximumLimit <= 0) {
|
||
throw new Error("La limite maximale des candidats replay retournée par le backend est invalide.");
|
||
}
|
||
maximumReplayCandidateLimit = options.maximumLimit;
|
||
element<HTMLElement>("#replayCandidateProfileBadge").textContent = `Profil ${options.activeProfileName}`;
|
||
element<HTMLElement>("#replayCandidateDsn").textContent = options.connectionDescriptor;
|
||
element<HTMLElement>("#replayCandidateMaximumLimit").textContent = String(options.maximumLimit);
|
||
document.querySelectorAll<HTMLInputElement>("input[type=number][max]").forEach(input => {
|
||
input.max = String(options.maximumLimit);
|
||
});
|
||
populateKnownPrograms(options.knownPrograms);
|
||
}
|
||
|
||
function populateKnownPrograms(programs: DemoStoreReplayKnownProgramOption[]): void {
|
||
const select = element<HTMLSelectElement>("#knownProgramSelect");
|
||
const datalist = element<HTMLDataListElement>("#knownProgramDatalist");
|
||
select.replaceChildren(new Option("Tous les programmes connus", ""));
|
||
datalist.replaceChildren();
|
||
for (const program of programs) {
|
||
const label = `${program.code} — ${program.programId}`;
|
||
select.add(new Option(label, program.programId));
|
||
const option = document.createElement("option");
|
||
option.value = program.programId;
|
||
option.label = program.code;
|
||
datalist.append(option);
|
||
}
|
||
}
|
||
|
||
function initializeTooltips(): void {
|
||
document.querySelectorAll<HTMLElement>('[data-bs-toggle="tooltip"]').forEach(target => {
|
||
bootstrap.Tooltip.getOrCreateInstance(target);
|
||
});
|
||
}
|
||
|
||
function adjustVisibleTables(): void {
|
||
transactionTable?.columns.adjust();
|
||
programTable?.columns.adjust();
|
||
mintTable?.columns.adjust();
|
||
ownerTable?.columns.adjust();
|
||
accountTable?.columns.adjust();
|
||
}
|
||
|
||
function installInlineCopyHandler(): void {
|
||
document.addEventListener("click", event => {
|
||
const source = event.target;
|
||
if (!(source instanceof Element)) {
|
||
return;
|
||
}
|
||
const button = source.closest<HTMLButtonElement>("button[data-copy-value]");
|
||
if (button === null) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const value = button.dataset.copyValue ?? "";
|
||
const label = button.dataset.copyLabel ?? "valeur";
|
||
void copyLines([value], label).catch(error => reportUiError(`copy ${label}`, error));
|
||
});
|
||
}
|
||
|
||
function reportUiError(action: string, caughtError: unknown): void {
|
||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||
element<HTMLElement>("#replayCandidateStatusBadge").textContent = `Erreur : ${message}`;
|
||
element<HTMLElement>("#replayCandidateStatusBadge").className = "badge text-bg-danger";
|
||
frontendError(tracingTarget, `${action} failed: ${message}`);
|
||
}
|
||
|
||
function installHandlers(): void {
|
||
element<HTMLButtonElement>("#loadTransactionsButton").addEventListener("click", () => void loadTransactions());
|
||
element<HTMLButtonElement>("#resetTransactionsButton").addEventListener("click", () => {
|
||
void resetTransactionFilters().catch(error => reportUiError("reset transaction filters", error));
|
||
});
|
||
element<HTMLButtonElement>("#loadProgramsButton").addEventListener("click", () => void loadPrograms());
|
||
element<HTMLButtonElement>("#resetProgramsButton").addEventListener("click", () => {
|
||
void resetProgramFilters().catch(error => reportUiError("reset program filters", error));
|
||
});
|
||
element<HTMLButtonElement>("#openCoreExtractionButton").addEventListener("click", () => void openCoreExtraction());
|
||
element<HTMLButtonElement>("#copySignaturesButton").addEventListener("click", () => {
|
||
void copyLines(transactionRowsForExport().map(row => row.signature), "signature").catch(error => reportUiError("copy signatures", error));
|
||
});
|
||
element<HTMLButtonElement>("#exportTransactionsCsvButton").addEventListener("click", () => {
|
||
void exportTransactionsCsv().catch(error => reportUiError("export transactions CSV", error));
|
||
});
|
||
element<HTMLButtonElement>("#copyProgramsButton").addEventListener("click", () => {
|
||
void copyLines(programRowsForExport().map(row => row.programId), "program ID").catch(error => reportUiError("copy programs", error));
|
||
});
|
||
element<HTMLButtonElement>("#exportProgramsCsvButton").addEventListener("click", () => {
|
||
void exportProgramsCsv().catch(error => reportUiError("export programs CSV", error));
|
||
});
|
||
element<HTMLButtonElement>("#useSelectedProgramButton").addEventListener("click", () => {
|
||
void useSelectedProgram().catch(error => reportUiError("use selected program", error));
|
||
});
|
||
for (const config of entityViewConfigs) {
|
||
element<HTMLButtonElement>(config.loadButtonSelector).addEventListener("click", () => void loadEntity(config));
|
||
element<HTMLButtonElement>(config.resetButtonSelector).addEventListener("click", () => {
|
||
void resetEntityFilters(config).catch(error => reportUiError(`reset ${config.pluralLabel} filters`, error));
|
||
});
|
||
element<HTMLButtonElement>(config.copyButtonSelector).addEventListener("click", () => {
|
||
void copyLines(entityRowsForExport(config.kind).map(row => row.entityValue), config.label).catch(error => reportUiError(`copy ${config.pluralLabel}`, error));
|
||
});
|
||
element<HTMLButtonElement>(config.exportButtonSelector).addEventListener("click", () => {
|
||
void exportEntityCsv(config).catch(error => reportUiError(`export ${config.pluralLabel} CSV`, error));
|
||
});
|
||
element<HTMLButtonElement>(config.useButtonSelector).addEventListener("click", () => {
|
||
void useSelectedEntity(config).catch(error => reportUiError(`use selected ${config.label}`, error));
|
||
});
|
||
}
|
||
element<HTMLSelectElement>("#knownProgramSelect").addEventListener("change", event => {
|
||
const target = event.currentTarget as HTMLSelectElement;
|
||
element<HTMLInputElement>("#programContainsInput").value = target.value;
|
||
});
|
||
document.querySelectorAll<HTMLButtonElement>('[data-bs-toggle="tab"]').forEach(button => {
|
||
button.addEventListener("shown.bs.tab", adjustVisibleTables);
|
||
});
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
installFrontendConsoleBridge(tracingTarget);
|
||
frontendDebug(tracingTarget, "Store replay candidate browser loaded");
|
||
transactionTable = createTransactionTable();
|
||
programTable = createProgramTable();
|
||
mintTable = createEntityTable(entityViewConfigs[0]);
|
||
ownerTable = createEntityTable(entityViewConfigs[1]);
|
||
accountTable = createEntityTable(entityViewConfigs[2]);
|
||
initializeTooltips();
|
||
installHandlers();
|
||
installInlineCopyHandler();
|
||
void loadOptions()
|
||
.then(() => Promise.all([
|
||
loadTransactions(),
|
||
loadPrograms(),
|
||
loadEntity(entityViewConfigs[0]),
|
||
loadEntity(entityViewConfigs[1]),
|
||
loadEntity(entityViewConfigs[2]),
|
||
]))
|
||
.catch(caughtError => reportUiError("initial loading", caughtError));
|
||
});
|