// file: crates/ksp-app-store-desk/frontend/ts/main.ts // version: 4 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 { 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 = { overview: "Overview", transactions: "RAW Transactions", accounts: "RAW Accounts", diagnostics: "Diagnostics", }; let transactionTable: StoreDataTable | null = null; let accountTable: StoreDataTable | null = null; 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(`#${elementId}`); if (element) { element.textContent = value; } } function setVisible(elementId: string, visible: boolean): void { const element = document.querySelector(`#${elementId}`); if (element) { element.hidden = !visible; } } function renderDiagnostic(elementId: string, diagnostic: { domain: string; code: string; message: string } | null): void { const element = document.querySelector(`#${elementId}`); if (!element) { return; } element.hidden = diagnostic === null; element.textContent = diagnostic ? `${diagnostic.domain}/${diagnostic.code}: ${diagnostic.message}` : ""; } function activateView(viewId: ViewId): void { document.querySelectorAll("[data-view-panel]").forEach(panel => { panel.hidden = panel.dataset.viewPanel !== viewId; }); document.querySelectorAll("[data-view]").forEach(button => { button.classList.toggle("active", button.dataset.view === viewId); }); const title = document.querySelector("#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("#transactionSlotMin"); const slotMaxElement = document.querySelector("#transactionSlotMax"); const directionElement = document.querySelector("#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 { return { data: [], draw, recordsFiltered: 0, recordsTotal: 0 }; } function setTransactionQueryError(message: string | null): void { const element = document.querySelector("#transactionsQueryError"); if (!element) { return; } element.hidden = message === null; element.textContent = message ?? ""; } async function queryTransactions(page: DataTablesPageRequest, callback: (response: DataTablesPageResponse) => void): Promise { 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("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 `${label}`; } 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: [ { className: "font-monospace", data: "signature" }, { className: "font-monospace", data: "slotDecimal" }, { data: "blockTimeUnixMillisDecimal", defaultContent: "—" }, { data: "formatId" }, { data: "formatVersion" }, { data: "payloadSizeDecimal", defaultContent: "—" }, { className: "font-monospace", data: "contentHash" }, { data: "retentionState", render: data => retentionBadge(String(data)) }, { data: null, defaultContent: "", render: (_data, _type, row) => { const transaction = row as StoreTransactionRowDto; return ``; }, }, ], 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 { frontendDebug("main", "Store Desk Store runtime refresh started"); try { const status = await invokeKsp("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 { frontendDebug("main", "Store Desk diagnostics refresh started"); try { const status = await invokeKsp("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); } function renderTransactionDetail(detail: StoreTransactionDetailDto): void { setText("transactionDetailSignature", detail.signature); setText("transactionDetailSlot", detail.slotDecimal); setText("transactionDetailBlockTime", detail.blockTimeUnixMillisDecimal ?? "—"); setText("transactionDetailFormat", `${detail.formatId} v${detail.formatVersion}`); setText("transactionDetailContentHash", detail.contentHash); setText("transactionDetailRetention", detail.retentionState); setText("transactionDetailPayloadSize", detail.payloadSizeDecimal ?? "—"); setText("transactionDetailPayloadPreview", detail.payloadPreviewHex ?? "Payload non disponible dans cet état de rétention."); setVisible("transactionDetailPreviewTruncated", detail.payloadPreviewTruncated); } async function openTransactionDetail(signature: string): Promise { const modalElement = document.querySelector("#transactionDetailModal"); if (!modalElement) { return; } clearTransactionDetail(); setText("transactionDetailSignature", 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("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("[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("#refreshOverview"); refreshOverview?.addEventListener("click", () => { frontendDebug("main", "Store Desk Overview refresh button clicked"); void refreshStoreRuntime(); }); const refreshTransactions = document.querySelector("#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(`#${elementId}`); control?.addEventListener("change", () => { frontendDebug("main", "Store Desk transaction Store filter changed", { controlId: elementId }); redrawTransactions(true, "store-filter"); }); } const refreshButton = document.querySelector("#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 detailButton = eventTarget.closest("[data-transaction-detail]"); if (detailButton) { const signature = detailButton.dataset.transactionDetail; if (signature) { void openTransactionDetail(signature); } return; } const control = eventTarget.closest("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("#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 { frontendInfo("main", "Store Desk main frontend loaded"); initializeTransactionTable(); initializeAccountSkeleton(); installInteractions(); activateView("overview"); await refreshDiagnostics(); } document.addEventListener("DOMContentLoaded", () => { void initializeMain(); });