Files
khadhroony-bot3/kb-app-demo-desktop/frontend/ts/demo_sql_replay_candidates.ts
2026-07-27 15:13:11 +02:00

801 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// file: kb-app-demo-desktop/frontend/ts/demo_sql_replay_candidates.ts
// version: 7
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 { DemoSqlReplayEntityRequest } from "./bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayEntityRequest";
import type { DemoSqlReplayEntityRow } from "./bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayEntityRow";
import type { DemoSqlReplayKnownProgramOption } from "./bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayKnownProgramOption";
import type { DemoSqlReplayOptionsPayload } from "./bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayOptionsPayload";
import type { DemoSqlReplayProgramRequest } from "./bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayProgramRequest";
import type { DemoSqlReplayProgramRow } from "./bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayProgramRow";
import type { DemoSqlReplayTransactionRequest } from "./bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayTransactionRequest";
import type { DemoSqlReplayTransactionRow } from "./bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayTransactionRow";
(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.frontend.demo_sql_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<DemoSqlReplayTransactionRow> | null = null;
let programTable: Api<DemoSqlReplayProgramRow> | null = null;
let mintTable: Api<DemoSqlReplayEntityRow> | null = null;
let ownerTable: Api<DemoSqlReplayEntityRow> | null = null;
let accountTable: Api<DemoSqlReplayEntityRow> | null = null;
let busyOperationCount = 0;
let maximumReplayCandidateLimit = 100_000;
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("&amp;")
.split("<").join("&lt;")
.split(">").join("&gt;")
.split('"').join("&quot;")
.split("'").join("&#039;");
}
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: DemoSqlReplayTransactionRow): string {
if (!row.hasCoreTransaction) {
return "absent";
}
if (row.transactionFailed === true) {
return "échec on-chain";
}
return "présent";
}
function coreDisplay(_value: unknown, type: string, row: DemoSqlReplayTransactionRow): 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: DemoSqlReplayEntityRow): 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: DemoSqlReplayEntityRow): 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<DemoSqlReplayTransactionRow> {
return new DataTable<DemoSqlReplayTransactionRow>("#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: "outerInstructionCount" },
{ data: "innerInstructionCount" },
{ data: "outerProgramCount" },
{ 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<DemoSqlReplayProgramRow> {
return new DataTable<DemoSqlReplayProgramRow>("#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: "outerInstructionCount" },
{ 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<DemoSqlReplayEntityRow> {
return new DataTable<DemoSqlReplayEntityRow>(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<DemoSqlReplayEntityRow> | 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(): DemoSqlReplayTransactionRequest {
const entityKind = optionalInputValue("#transactionEntityKindSelect");
const entityValue = optionalInputValue("#transactionEntityValueInput");
if ((entityKind === null) !== (entityValue === null)) {
throw new Error("Le type dentité 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<DemoSqlReplayTransactionRow[]>("load_demo_sql_replay_transactions", { request });
replaceRows(transactionTable, rows);
element<HTMLElement>("#transactionResultSummary").textContent = `${rows.length} transaction(s) chargée(s) depuis PostgreSQL.`;
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: DemoSqlReplayProgramRequest = {
programIdContains: optionalInputValue("#programContainsInput"),
limit: boundedPositiveIntegerValue("#programLimitInput"),
};
const rows = await invoke<DemoSqlReplayProgramRow[]>("load_demo_sql_replay_programs", { request });
replaceRows(programTable, rows);
element<HTMLElement>("#programResultSummary").textContent = `${rows.length} programme(s) chargé(s), avec occurrences outer, 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: DemoSqlReplayEntityRequest = {
entityKind: config.kind,
entityValueContains: optionalInputValue(config.containsSelector),
limit: boundedPositiveIntegerValue(config.limitSelector),
};
const rows = await invoke<DemoSqlReplayEntityRow[]>("load_demo_sql_replay_entities", { request });
replaceRows(table, rows);
element<HTMLElement>(config.summarySelector).textContent = `${rows.length} ${config.pluralLabel} chargé(s) depuis PostgreSQL.`;
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_sql_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", "outer_instruction_count", "inner_instruction_count", "outer_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.outerInstructionCount, row.innerInstructionCount, row.outerProgramCount, 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", "outer_instruction_count", "inner_instruction_count", "log_count", "min_slot", "max_slot"],
rows.map(row => [row.programCode, row.programId, row.transactionCount, row.outerInstructionCount, 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(): DemoSqlReplayTransactionRow[] {
if (transactionTable === null) {
return [];
}
return selectedOrFilteredRows(transactionTable);
}
function programRowsForExport(): DemoSqlReplayProgramRow[] {
if (programTable === null) {
return [];
}
return selectedOrFilteredRows(programTable);
}
function entityRowsForExport(kind: EntityKindCode): DemoSqlReplayEntityRow[] {
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<DemoSqlReplayOptionsPayload>("demo_sql_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.maskedDsn;
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: DemoSqlReplayKnownProgramOption[]): 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, "SQL 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));
});