Files
khadhroony-solana-project/crates/ksp-app-store-desk/frontend/ts/main.ts

593 lines
27 KiB
TypeScript

// file: crates/ksp-app-store-desk/frontend/ts/main.ts
// version: 6
import DataTable from "datatables.net-bs5";
import "datatables.net-bs5/css/dataTables.bootstrap5.css";
import { Modal } from "bootstrap";
import ResizeObserver from "resize-observer-polyfill";
import "simplebar";
import type { ShellStatusDto } from "./bindings/ksp_app_store_desk/dto_common/ShellStatusDto.ts";
import type { StoreRuntimeStatusDto } from "./bindings/ksp_app_store_desk/dto_common/StoreRuntimeStatusDto.ts";
import type { StoreTransactionDetailDto } from "./bindings/ksp_app_store_desk/dto_transaction/StoreTransactionDetailDto.ts";
import type { StoreTransactionDetailRequestDto } from "./bindings/ksp_app_store_desk/dto_transaction/StoreTransactionDetailRequestDto.ts";
import type { StoreTransactionQueryRequestDto } from "./bindings/ksp_app_store_desk/dto_transaction/StoreTransactionQueryRequestDto.ts";
import type { StoreTransactionQueryResponseDto } from "./bindings/ksp_app_store_desk/dto_transaction/StoreTransactionQueryResponseDto.ts";
import type { StoreTransactionRowDto } from "./bindings/ksp_app_store_desk/dto_transaction/StoreTransactionRowDto.ts";
import { frontendDebug, frontendError, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
import { invokeKsp } from "./invoke";
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
installFrontendConsoleBridge("main");
type ViewId = "overview" | "transactions" | "accounts" | "diagnostics";
interface DataTablesPageRequest {
draw: number;
length: number;
start: number;
}
interface DataTablesPageResponse<T> {
data: T[];
draw: number;
recordsFiltered: number;
recordsTotal: number;
}
interface StoreDataTable {
columns: { adjust(): void };
draw(resetPaging?: boolean): void;
on(event: string, callback: (...args: unknown[]) => void): StoreDataTable;
}
const viewTitles: Record<ViewId, string> = {
overview: "Overview",
transactions: "RAW Transactions",
accounts: "RAW Accounts",
diagnostics: "Diagnostics",
};
let transactionTable: StoreDataTable | null = null;
let accountTable: StoreDataTable | null = null;
const COPY_FEEDBACK_MILLISECONDS = 1200;
const LONG_TEXT_HEAD_CHARACTERS = 12;
const LONG_TEXT_TAIL_CHARACTERS = 8;
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function truncateLongText(value: string): string {
const threshold = LONG_TEXT_HEAD_CHARACTERS + LONG_TEXT_TAIL_CHARACTERS + 1;
if (value.length <= threshold) {
return value;
}
return `${value.slice(0, LONG_TEXT_HEAD_CHARACTERS)}${value.slice(-LONG_TEXT_TAIL_CHARACTERS)}`;
}
function renderCopyableLongText(value: string, fieldId: string): string {
const escapedValue = escapeHtml(value);
const escapedVisible = escapeHtml(truncateLongText(value));
const escapedFieldId = escapeHtml(fieldId);
return `<span class="app-copyable-long-text"><span class="app-copyable-long-text__value font-monospace" title="${escapedValue}">${escapedVisible}</span><button class="btn btn-sm btn-outline-secondary app-copyable-long-text__copy" type="button" data-copy-long-text="${escapedValue}" data-copy-field="${escapedFieldId}" title="Copier la valeur complète" aria-label="Copier la valeur complète"><i class="fa-regular fa-copy" aria-hidden="true"></i></button></span>`;
}
function setCopyableLongText(elementId: string, value: string, fieldId: string): void {
const element = document.querySelector<HTMLElement>(`#${elementId}`);
if (!element) {
return;
}
element.replaceChildren();
const wrapper = document.createElement("span");
wrapper.className = "app-copyable-long-text";
const text = document.createElement("span");
text.className = "app-copyable-long-text__value font-monospace";
text.textContent = truncateLongText(value);
text.title = value;
const button = document.createElement("button");
button.className = "btn btn-sm btn-outline-secondary app-copyable-long-text__copy";
button.type = "button";
button.dataset.copyLongText = value;
button.dataset.copyField = fieldId;
button.title = "Copier la valeur complète";
button.setAttribute("aria-label", "Copier la valeur complète");
button.innerHTML = '<i class="fa-regular fa-copy" aria-hidden="true"></i>';
wrapper.append(text, button);
element.append(wrapper);
}
async function writeClipboardText(value: string): Promise<boolean> {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {
// Fall back to the document copy command for WebViews without Clipboard API permission.
}
}
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-10000px";
textarea.style.top = "-10000px";
document.body.append(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
return copied;
}
async function copyLongText(button: HTMLButtonElement, value: string, fieldId: string): Promise<void> {
frontendDebug("main", "Store Desk long text copy requested", { fieldId });
const copied = await writeClipboardText(value);
if (!copied) {
frontendError("main", "Store Desk long text copy failed", { fieldId });
return;
}
const icon = button.querySelector<HTMLElement>("i");
const previousClassName = icon?.className ?? "";
const previousTitle = button.title;
if (icon) {
icon.className = "fa-solid fa-check";
}
button.title = "Copié";
frontendDebug("main", "Store Desk long text copy completed", { fieldId });
window.setTimeout(() => {
if (icon) {
icon.className = previousClassName;
}
button.title = previousTitle;
}, COPY_FEEDBACK_MILLISECONDS);
}
function isViewId(value: string | undefined): value is ViewId {
return value === "overview" || value === "transactions" || value === "accounts" || value === "diagnostics";
}
function setText(elementId: string, value: string): void {
const element = document.querySelector<HTMLElement>(`#${elementId}`);
if (element) {
element.textContent = value;
}
}
function setVisible(elementId: string, visible: boolean): void {
const element = document.querySelector<HTMLElement>(`#${elementId}`);
if (element) {
element.hidden = !visible;
}
}
function renderDiagnostic(elementId: string, diagnostic: { domain: string; code: string; message: string } | null): void {
const element = document.querySelector<HTMLElement>(`#${elementId}`);
if (!element) {
return;
}
element.hidden = diagnostic === null;
element.textContent = diagnostic ? `${diagnostic.domain}/${diagnostic.code}: ${diagnostic.message}` : "";
}
function activateView(viewId: ViewId): void {
document.querySelectorAll<HTMLElement>("[data-view-panel]").forEach(panel => {
panel.hidden = panel.dataset.viewPanel !== viewId;
});
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
button.classList.toggle("active", button.dataset.view === viewId);
});
const title = document.querySelector<HTMLElement>("#headerViewTitle");
if (title) {
title.textContent = viewTitles[viewId];
}
document.title = `Store Desk — ${viewTitles[viewId]}`;
if (viewId === "transactions") {
transactionTable?.columns.adjust();
}
if (viewId === "accounts") {
accountTable?.columns.adjust();
}
frontendDebug("main", "Store Desk view activated", { viewId });
}
function decimalCountToSafeNumber(value: string): number | null {
if (!/^\d+$/.test(value)) {
return null;
}
const exact = BigInt(value);
if (exact > BigInt(Number.MAX_SAFE_INTEGER)) {
return null;
}
return Number(exact);
}
function currentTransactionQuery(page: DataTablesPageRequest): StoreTransactionQueryRequestDto | null {
if (!Number.isSafeInteger(page.start) || page.start < 0 || !Number.isSafeInteger(page.length) || ![25, 50, 100].includes(page.length)) {
return null;
}
const slotMinElement = document.querySelector<HTMLInputElement>("#transactionSlotMin");
const slotMaxElement = document.querySelector<HTMLInputElement>("#transactionSlotMax");
const directionElement = document.querySelector<HTMLSelectElement>("#transactionDirection");
const slotMin = slotMinElement?.value.trim() ?? "";
const slotMax = slotMaxElement?.value.trim() ?? "";
return {
direction: directionElement?.value ?? "descending",
limit: page.length,
offset: page.start.toString(),
slotMax: slotMax === "" ? null : slotMax,
slotMin: slotMin === "" ? null : slotMin,
};
}
function emptyTransactionPage(draw: number): DataTablesPageResponse<StoreTransactionRowDto> {
return { data: [], draw, recordsFiltered: 0, recordsTotal: 0 };
}
function setTransactionQueryError(message: string | null): void {
const element = document.querySelector<HTMLElement>("#transactionsQueryError");
if (!element) {
return;
}
element.hidden = message === null;
element.textContent = message ?? "";
}
async function queryTransactions(page: DataTablesPageRequest, callback: (response: DataTablesPageResponse<StoreTransactionRowDto>) => void): Promise<void> {
const draw = Number.isSafeInteger(page.draw) && page.draw >= 0 ? page.draw : 0;
const request = currentTransactionQuery(page);
if (!request) {
setTransactionQueryError("La pagination demandée n'est pas admise par Store Desk.");
callback(emptyTransactionPage(draw));
frontendError("main", "Store Desk transaction DataTables query rejected before IPC");
return;
}
setTransactionQueryError(null);
frontendDebug("main", "Store Desk transaction DataTables server-side query started", {
pageLength: request.limit,
slotFilterActive: request.slotMin !== null || request.slotMax !== null,
});
try {
const response = await invokeKsp<StoreTransactionQueryResponseDto>("main", "store_query_transactions", { request });
const recordsTotal = decimalCountToSafeNumber(response.recordsTotalDecimal);
const recordsFiltered = decimalCountToSafeNumber(response.recordsFilteredDecimal);
if (recordsTotal === null || recordsFiltered === null) {
setTransactionQueryError("Le volume Store dépasse le domaine entier sûr de l'interface.");
callback(emptyTransactionPage(draw));
frontendError("main", "Store Desk transaction DataTables exact count exceeds frontend safe integer domain");
return;
}
callback({ data: response.rows, draw, recordsFiltered, recordsTotal });
frontendDebug("main", "Store Desk transaction DataTables server-side query completed", { rowCount: response.rows.length });
} catch {
setTransactionQueryError("La requête RAW Transactions a échoué.");
callback(emptyTransactionPage(draw));
frontendError("main", "Store Desk transaction DataTables server-side query failed");
}
}
function retentionBadge(retentionState: string): string {
const label = retentionState === "full" ? "Full" : retentionState === "archived" ? "Archived" : retentionState === "purged" ? "Purged" : retentionState;
const className = retentionState === "full" ? "text-bg-success" : retentionState === "archived" ? "text-bg-info" : retentionState === "purged" ? "text-bg-secondary" : "text-bg-warning";
return `<span class="badge ${className}">${label}</span>`;
}
function initializeTransactionTable(): void {
transactionTable = new DataTable("#rawTransactionsTable", {
ajax: (data, callback) => {
const request = data as unknown as DataTablesPageRequest;
void queryTransactions(request, response => callback(response));
},
autoWidth: false,
columns: [
{ data: "signature", render: (data, type) => (type === "display" ? renderCopyableLongText(String(data), "transaction-signature") : String(data)) },
{ className: "font-monospace", data: "slotDecimal" },
{ data: "blockTimeUnixMillisDecimal", defaultContent: "—" },
{ data: "formatId" },
{ data: "formatVersion" },
{ data: "payloadSizeDecimal", defaultContent: "—" },
{ data: "contentHash", render: (data, type) => (type === "display" ? renderCopyableLongText(String(data), "transaction-content-hash") : String(data)) },
{ data: "retentionState", render: data => retentionBadge(String(data)) },
{
data: null,
defaultContent: "",
render: (_data, _type, row) => {
const transaction = row as StoreTransactionRowDto;
return `<button class="btn btn-sm btn-outline-primary" type="button" data-transaction-detail="${escapeHtml(transaction.signature)}"><i class="fa-solid fa-magnifying-glass me-1" aria-hidden="true"></i>Détail</button>`;
},
},
],
info: true,
lengthMenu: [25, 50, 100],
ordering: false,
pageLength: 25,
paging: true,
processing: true,
searching: false,
serverSide: true,
scrollX: true,
language: {
emptyTable: "Aucune transaction RAW ne correspond à la requête Store.",
info: "_START_ à _END_ sur _TOTAL_ transaction(s)",
infoEmpty: "0 transaction",
lengthMenu: "Afficher _MENU_",
loadingRecords: "Chargement Store…",
processing: "Inspection Store…",
zeroRecords: "Aucune transaction RAW correspondante.",
},
}) as unknown as StoreDataTable;
transactionTable.on("page", () => {
frontendDebug("main", "Store Desk transaction DataTables page changed");
});
transactionTable.on("length", () => {
frontendDebug("main", "Store Desk transaction DataTables page length changed");
});
transactionTable.on("draw", () => {
frontendTrace("main", "Store Desk transaction DataTables redraw completed");
});
frontendTrace("main", "Store Desk transaction DataTable initialized", { pagingOwner: "datatables", serverSide: true });
}
function initializeAccountSkeleton(): void {
accountTable = new DataTable("#rawAccountsTable", {
autoWidth: false,
data: [],
info: true,
lengthMenu: [25, 50, 100],
ordering: false,
pageLength: 25,
paging: true,
searching: false,
serverSide: false,
scrollX: true,
language: {
emptyTable: "Aucune donnée RAW Account n'est chargée avant la tranche pre.008.",
},
}) as unknown as StoreDataTable;
frontendTrace("main", "Store Desk account DataTable skeleton initialized", { pagingOwner: "datatables", serverSide: false });
}
function renderShellStatus(status: ShellStatusDto): void {
setText("appVersionBadge", status.applicationVersion);
setText("runtimeVersion", status.applicationVersion);
setText("runtimeShellPhase", status.shellPhase);
setText("runtimeConfigDocuments", status.configDocumentCount.toString());
setText("runtimeLoggingProfile", status.activeLoggingProfile ?? "fallback");
setText("runtimeLoggingFallback", status.fallbackLoggingActive ? "oui" : "non");
renderDiagnostic("runtimeDiagnostic", status.startupDiagnostic);
frontendTrace("main", "Store Desk shell status rendered", {
fallbackLoggingActive: status.fallbackLoggingActive,
shellPhase: status.shellPhase,
});
}
function renderStoreRuntimeStatus(status: StoreRuntimeStatusDto): void {
const target = status.backendKind && status.network ? `${status.backendKind} / ${status.network}` : status.network ?? status.backendKind ?? "indisponible";
const migration = status.migrationVersionDecimal === null ? `inconnue · ${status.pendingMigrationCount} pending` : `v${status.migrationVersionDecimal} · ${status.pendingMigrationCount} pending`;
const pool = `${status.poolAvailable}/${status.poolSize}/${status.poolCapacity} · wait ${status.poolWaiting}`;
const profile = status.profileId ?? "indisponible";
setText("overviewStoreProfile", profile);
setText("overviewStoreTarget", target);
setText("overviewStoreHealth", status.healthState);
setText("overviewStorePool", pool);
setText("overviewStoreMigration", migration);
renderDiagnostic("overviewStoreDiagnostic", status.diagnostic);
setText("runtimeStoreProfile", profile);
setText("runtimeStoreTarget", target);
setText("runtimeStoreHealth", status.healthState);
setText("runtimeStoreMigration", migration);
setText("runtimeStorePool", pool);
renderDiagnostic("runtimeStoreDiagnostic", status.diagnostic);
frontendTrace("main", "Store Desk Store runtime status rendered", {
healthState: status.healthState,
pendingMigrationCount: status.pendingMigrationCount,
poolCapacity: status.poolCapacity,
poolSize: status.poolSize,
storeOpen: status.storeOpen,
});
}
async function refreshStoreRuntime(): Promise<void> {
frontendDebug("main", "Store Desk Store runtime refresh started");
try {
const status = await invokeKsp<StoreRuntimeStatusDto>("main", "store_runtime_status");
renderStoreRuntimeStatus(status);
frontendDebug("main", "Store Desk Store runtime refresh completed", { healthState: status.healthState, storeOpen: status.storeOpen });
} catch {
frontendError("main", "Store Desk Store runtime refresh failed");
}
}
async function refreshDiagnostics(): Promise<void> {
frontendDebug("main", "Store Desk diagnostics refresh started");
try {
const status = await invokeKsp<ShellStatusDto>("main", "get_shell_status");
renderShellStatus(status);
await refreshStoreRuntime();
frontendDebug("main", "Store Desk diagnostics refresh completed");
} catch {
frontendError("main", "Store Desk diagnostics refresh failed");
}
}
function clearTransactionDetail(): void {
for (const elementId of [
"transactionDetailSignature",
"transactionDetailSlot",
"transactionDetailBlockTime",
"transactionDetailFormat",
"transactionDetailContentHash",
"transactionDetailRetention",
"transactionDetailPayloadSize",
"transactionDetailPayloadPreview",
]) {
setText(elementId, "—");
}
setVisible("transactionDetailError", false);
setVisible("transactionDetailPreviewTruncated", false);
const payloadPreviewCopy = document.querySelector<HTMLButtonElement>("#transactionDetailPayloadPreviewCopy");
if (payloadPreviewCopy) {
delete payloadPreviewCopy.dataset.copyLongText;
delete payloadPreviewCopy.dataset.copyField;
payloadPreviewCopy.hidden = true;
}
}
function renderTransactionDetail(detail: StoreTransactionDetailDto): void {
setCopyableLongText("transactionDetailSignature", detail.signature, "transaction-detail-signature");
setText("transactionDetailSlot", detail.slotDecimal);
setText("transactionDetailBlockTime", detail.blockTimeUnixMillisDecimal ?? "—");
setText("transactionDetailFormat", `${detail.formatId} v${detail.formatVersion}`);
setCopyableLongText("transactionDetailContentHash", detail.contentHash, "transaction-detail-content-hash");
setText("transactionDetailRetention", detail.retentionState);
setText("transactionDetailPayloadSize", detail.payloadSizeDecimal ?? "—");
const payloadPreview = detail.payloadPreviewHex;
setText("transactionDetailPayloadPreview", payloadPreview ?? "Payload non disponible dans cet état de rétention.");
const payloadPreviewCopy = document.querySelector<HTMLButtonElement>("#transactionDetailPayloadPreviewCopy");
if (payloadPreviewCopy && payloadPreview) {
payloadPreviewCopy.dataset.copyLongText = payloadPreview;
payloadPreviewCopy.dataset.copyField = "transaction-payload-preview";
payloadPreviewCopy.hidden = false;
} else if (payloadPreviewCopy) {
delete payloadPreviewCopy.dataset.copyLongText;
delete payloadPreviewCopy.dataset.copyField;
payloadPreviewCopy.hidden = true;
}
setVisible("transactionDetailPreviewTruncated", detail.payloadPreviewTruncated);
}
async function openTransactionDetail(signature: string): Promise<void> {
const modalElement = document.querySelector<HTMLElement>("#transactionDetailModal");
if (!modalElement) {
return;
}
clearTransactionDetail();
setCopyableLongText("transactionDetailSignature", signature, "transaction-detail-signature");
frontendDebug("main", "Store Desk transaction detail load requested");
const modal = Modal.getOrCreateInstance(modalElement);
modal.show();
const request: StoreTransactionDetailRequestDto = { signature };
try {
const detail = await invokeKsp<StoreTransactionDetailDto>("main", "store_get_transaction_detail", { request });
renderTransactionDetail(detail);
frontendDebug("main", "Store Desk transaction detail opened", { payloadPreviewTruncated: detail.payloadPreviewTruncated, retentionState: detail.retentionState });
} catch {
setText("transactionDetailError", "Le détail de cette transaction n'a pas pu être chargé.");
setVisible("transactionDetailError", true);
frontendError("main", "Store Desk transaction detail load failed");
}
}
function redrawTransactions(resetPaging: boolean, reason: string): void {
frontendDebug("main", "Store Desk transaction DataTables redraw requested", { reason });
transactionTable?.draw(resetPaging);
}
function installInteractions(): void {
document.querySelectorAll<HTMLButtonElement>("[data-view]").forEach(button => {
button.addEventListener("click", () => {
const viewId = button.dataset.view;
frontendDebug("main", "Store Desk navigation button clicked", { viewId: viewId ?? "missing" });
if (isViewId(viewId)) {
activateView(viewId);
}
});
});
const refreshOverview = document.querySelector<HTMLButtonElement>("#refreshOverview");
refreshOverview?.addEventListener("click", () => {
frontendDebug("main", "Store Desk Overview refresh button clicked");
void refreshStoreRuntime();
});
const refreshTransactions = document.querySelector<HTMLButtonElement>("#refreshTransactions");
refreshTransactions?.addEventListener("click", () => {
frontendDebug("main", "Store Desk RAW Transactions refresh button clicked");
redrawTransactions(false, "refresh");
});
for (const elementId of ["transactionSlotMin", "transactionSlotMax", "transactionDirection"]) {
const control = document.querySelector<HTMLElement>(`#${elementId}`);
control?.addEventListener("change", () => {
frontendDebug("main", "Store Desk transaction Store filter changed", { controlId: elementId });
redrawTransactions(true, "store-filter");
});
}
const refreshButton = document.querySelector<HTMLButtonElement>("#refreshDiagnostics");
refreshButton?.addEventListener("click", () => {
frontendDebug("main", "Store Desk diagnostics refresh button clicked");
void refreshDiagnostics();
});
document.addEventListener("click", event => {
const eventTarget = event.target;
if (!(eventTarget instanceof Element)) {
return;
}
const copyButton = eventTarget.closest<HTMLButtonElement>("[data-copy-long-text]");
if (copyButton) {
const value = copyButton.dataset.copyLongText;
const fieldId = copyButton.dataset.copyField ?? "long-text";
if (value) {
void copyLongText(copyButton, value, fieldId);
}
return;
}
const detailButton = eventTarget.closest<HTMLElement>("[data-transaction-detail]");
if (detailButton) {
const signature = detailButton.dataset.transactionDetail;
if (signature) {
void openTransactionDetail(signature);
}
return;
}
const control = eventTarget.closest<HTMLElement>("button, [role='tab'], [data-bs-toggle='tab']");
if (!control) {
return;
}
if (control instanceof HTMLButtonElement && (control.dataset.view || control.id === "refreshDiagnostics" || control.id === "refreshOverview" || control.id === "refreshTransactions")) {
return;
}
const controlId = control.id || control.getAttribute("data-bs-target") || control.getAttribute("aria-controls") || "anonymous";
if (control.matches("[role='tab'], [data-bs-toggle='tab']")) {
frontendDebug("main", "Store Desk tab control clicked", { controlId });
return;
}
frontendDebug("main", "Store Desk generic button clicked", { buttonId: controlId });
});
document.addEventListener("shown.bs.tab", event => {
const target = event.target;
const tabId = target instanceof HTMLElement ? target.id || target.getAttribute("data-bs-target") || target.textContent?.trim() || "anonymous" : "unknown";
frontendDebug("main", "Store Desk tab activated", { tabId });
});
document.addEventListener("change", event => {
const target = event.target;
if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement)) {
return;
}
frontendDebug("main", "Store Desk interactive control changed", {
controlId: target.id || target.getAttribute("name") || "anonymous",
controlType: target instanceof HTMLSelectElement ? "select" : target.type || "text",
});
});
const detailModal = document.querySelector<HTMLElement>("#transactionDetailModal");
detailModal?.addEventListener("hidden.bs.modal", () => {
clearTransactionDetail();
frontendDebug("main", "Store Desk transaction detail closed");
});
frontendTrace("main", "Store Desk frontend interactions installed", { buttons: true, controls: true, datatables: true, tabs: true });
}
async function initializeMain(): Promise<void> {
frontendInfo("main", "Store Desk main frontend loaded");
initializeTransactionTable();
initializeAccountSkeleton();
installInteractions();
activateView("overview");
await refreshDiagnostics();
}
document.addEventListener("DOMContentLoaded", () => {
void initializeMain();
});