From 50fd7467f8b443f4165547027b67239fabe9c883 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sat, 25 Jul 2026 21:04:56 +0200 Subject: [PATCH] v0.1.0-pre.050 --- kb-app-demo-desktop/Cargo.toml | 3 +- kb-app-demo-desktop/capabilities/default.json | 11 +- .../frontend/demo_sql_diag.html | 115 +++ .../frontend/demo_sql_pg_core.html | 102 +++ .../frontend/demo_sql_pg_raw.html | 102 +++ .../frontend/demo_sql_replay_candidates.html | 443 ++++++++++ kb-app-demo-desktop/frontend/main.html | 5 + .../frontend/ts/demo_sql_diag.ts | 60 ++ .../frontend/ts/demo_sql_pg_core.ts | 42 + .../frontend/ts/demo_sql_pg_raw.ts | 42 + .../frontend/ts/demo_sql_replay_candidates.ts | 800 ++++++++++++++++++ .../frontend/ts/demo_sql_tables.ts | 68 ++ kb-app-demo-desktop/frontend/ts/main.ts | 18 +- kb-app-demo-desktop/src/app_state.rs | 35 +- kb-app-demo-desktop/src/demo_sql_common.rs | 363 ++++++++ kb-app-demo-desktop/src/demo_sql_diag.rs | 38 + kb-app-demo-desktop/src/demo_sql_pg_core.rs | 22 + kb-app-demo-desktop/src/demo_sql_pg_raw.rs | 22 + .../src/demo_sql_replay_candidates.rs | 409 +++++++++ kb-app-demo-desktop/src/demo_ws.rs | 6 +- kb-app-demo-desktop/src/lib.rs | 59 ++ kb-app-demo-desktop/src/tauri.rs | 380 ++++++++- kb-app-demo-desktop/vite.config.ts | 11 +- 23 files changed, 3129 insertions(+), 27 deletions(-) create mode 100644 kb-app-demo-desktop/frontend/demo_sql_diag.html create mode 100644 kb-app-demo-desktop/frontend/demo_sql_pg_core.html create mode 100644 kb-app-demo-desktop/frontend/demo_sql_pg_raw.html create mode 100644 kb-app-demo-desktop/frontend/demo_sql_replay_candidates.html create mode 100644 kb-app-demo-desktop/frontend/ts/demo_sql_diag.ts create mode 100644 kb-app-demo-desktop/frontend/ts/demo_sql_pg_core.ts create mode 100644 kb-app-demo-desktop/frontend/ts/demo_sql_pg_raw.ts create mode 100644 kb-app-demo-desktop/frontend/ts/demo_sql_replay_candidates.ts create mode 100644 kb-app-demo-desktop/frontend/ts/demo_sql_tables.ts create mode 100644 kb-app-demo-desktop/src/demo_sql_common.rs create mode 100644 kb-app-demo-desktop/src/demo_sql_diag.rs create mode 100644 kb-app-demo-desktop/src/demo_sql_pg_core.rs create mode 100644 kb-app-demo-desktop/src/demo_sql_pg_raw.rs create mode 100644 kb-app-demo-desktop/src/demo_sql_replay_candidates.rs diff --git a/kb-app-demo-desktop/Cargo.toml b/kb-app-demo-desktop/Cargo.toml index c432bad..d42b7e0 100644 --- a/kb-app-demo-desktop/Cargo.toml +++ b/kb-app-demo-desktop/Cargo.toml @@ -1,5 +1,5 @@ # file: kb-app-demo-desktop/Cargo.toml -# version: 2 +# version: 3 [package] name = "kb-app-demo-desktop" @@ -28,6 +28,7 @@ kb-core = { path = "../kb-core" } kb-logging = { path = "../kb-logging" } kb-store = { path = "../kb-store" } kb-pipeline = { path = "../kb-pipeline" } +kb-program-ids = { path = "../kb-program-ids" } kb-onchain-transport = { path = "../kb-onchain-transport" } serde.workspace = true serde_json.workspace = true diff --git a/kb-app-demo-desktop/capabilities/default.json b/kb-app-demo-desktop/capabilities/default.json index b9e4a9f..1902a52 100644 --- a/kb-app-demo-desktop/capabilities/default.json +++ b/kb-app-demo-desktop/capabilities/default.json @@ -4,9 +4,16 @@ "description": "Default desktop demo capability", "windows": [ "splash", - "main" + "main", + "demo_backfill", + "demo_http", + "demo_ws", + "demo_sql_diag", + "demo_sql_pg_raw", + "demo_sql_pg_core", + "demo_sql_replay_candidates" ], "permissions": [ "core:default" ] -} \ No newline at end of file +} diff --git a/kb-app-demo-desktop/frontend/demo_sql_diag.html b/kb-app-demo-desktop/frontend/demo_sql_diag.html new file mode 100644 index 0000000..959b4f6 --- /dev/null +++ b/kb-app-demo-desktop/frontend/demo_sql_diag.html @@ -0,0 +1,115 @@ + + + + + + + + + Khadhroony-Bot3 - SQL diagnostics + + + + +
+ +
+ +
+
+
+
+
+
+
+
+

Diagnostic PostgreSQL

+
+ + +
+
+
+
Statut
+
Chargement...
+
Fichier config
+
+
Profil
+
+
Backend
+
+
DSN
+
+
Schema courant
+
+
Healthcheck
+
+
Migrations
+
+
+
+
+
+ +
+
+
+

Tables raw/core connues

+
+ + + + + + + + + + + + + + +
TableDomaineRôleExisteLignesSlot minSlot maxDernière insertion
+
+
+
+
+ +
+
+
+

Payload JSON

+
Chargement...
+
+
+
+
+
+
+
+ +
+
+
+
+ © 2026 SASEDEV +
+
+
+
+ + + + + \ No newline at end of file diff --git a/kb-app-demo-desktop/frontend/demo_sql_pg_core.html b/kb-app-demo-desktop/frontend/demo_sql_pg_core.html new file mode 100644 index 0000000..4f9a914 --- /dev/null +++ b/kb-app-demo-desktop/frontend/demo_sql_pg_core.html @@ -0,0 +1,102 @@ + + + + + + + + + Khadhroony-Bot3 - PostgreSQL core store + + + + +
+ +
+ +
+
+
+
+
+
+
+
+

Core store Solana

+ +
+
+
Statut
+
Chargement...
+
Profil
+
+
DSN
+
+
+
+
+
+ +
+
+
+

Tables core

+
+ + + + + + + + + + + + + + +
TableDomaineRôleExisteLignesSlot minSlot maxDernière insertion
+
+
+
+
+ +
+
+
+

Payload JSON

+
Chargement...
+
+
+
+
+
+
+
+ +
+
+
+
+ © 2026 SASEDEV +
+
+
+
+ + + + + \ No newline at end of file diff --git a/kb-app-demo-desktop/frontend/demo_sql_pg_raw.html b/kb-app-demo-desktop/frontend/demo_sql_pg_raw.html new file mode 100644 index 0000000..0ac4899 --- /dev/null +++ b/kb-app-demo-desktop/frontend/demo_sql_pg_raw.html @@ -0,0 +1,102 @@ + + + + + + + + + Khadhroony-Bot3 - PostgreSQL canonical acquisition + + + + +
+ +
+ +
+
+
+
+
+
+
+
+

Acquisition transactionnelle Solana

+ +
+
+
Statut
+
Chargement...
+
Profil
+
+
DSN
+
+
+
+
+
+ +
+
+
+

Transactions canoniques et observations

+
+ + + + + + + + + + + + + + +
TableDomaineRôleExisteLignesSlot minSlot maxDernière insertion
+
+
+
+
+ +
+
+
+

Payload JSON

+
Chargement...
+
+
+
+
+
+
+
+ +
+
+
+
+ © 2026 SASEDEV +
+
+
+
+ + + + + diff --git a/kb-app-demo-desktop/frontend/demo_sql_replay_candidates.html b/kb-app-demo-desktop/frontend/demo_sql_replay_candidates.html new file mode 100644 index 0000000..e2b84ef --- /dev/null +++ b/kb-app-demo-desktop/frontend/demo_sql_replay_candidates.html @@ -0,0 +1,443 @@ + + + + + + + + + Khadhroony Bot3 - Candidats de replay SQL + + + + +
+ +
+ +
+
+
+
+
+

Exploration read-only des candidats

+

Les requêtes sont paramétrées, bornées et sans SQL libre. Utilisez les cases à cocher pour sélectionner les lignes. La case du header sélectionne ou désélectionne toutes les lignes correspondant au filtre local. Sans sélection explicite, la copie et l’export CSV utilisent toutes les lignes filtrées. Les CSV UTF-8 avec BOM et séparateur point-virgule sont écrits par le backend Tauri dans ./data/exports_csv/. LibreOffice Calc peut toujours afficher son dialogue d’import, car le format CSV ne décrit pas formellement son encodage ni son séparateur.

+
+ DSN : + + Limite serveur maximale : + +
+
+
+ + + +
+
+
+
+

Filtres PostgreSQL bornés

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
+
+
+

Transactions candidates

+
Aucune requête exécutée.
+
+
+
+ + +
+
Les identifiants longs sont tronqués à l’écran. Survolez-les pour afficher leur valeur complète ou utilisez le bouton de copie individuel.
+
+
+
+ + + + + + + + + + + + + + + + + + +
SélectionSignatureSlotRawCoreLedgerVersionEssaisOuter inst.Inner inst.Outer pr.Inner pr.Mise à jour
+
+
+
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+
+
+

Programmes indexés

+
Aucune requête exécutée.
+
+
+
+ + + +
+
Cochez un programme. Sans sélection, l’action accepte une seule ligne restant après le filtre local.
+
+
+
+ + + + + + + + + + + + + + +
SélectionCode connuProgram IDTransactionsOuterInnerLogs liésSlot minSlot max
+
+
+
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+
+
+

Mints

+
Aucune requête exécutée.
+
Source : kb_sol_core_balance_changes.mint.
+
+
+
+ + + +
+
Cochez une ligne. Sans sélection, l’action accepte une seule ligne restant après le filtre local.
+
+
+
+ + + + + + + + + + + + + +
SélectionMintTransactionsOccurrencesOcc./txSlot minSlot maxÉtendue
+
+
+
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+
+
+

Owners

+
Aucune requête exécutée.
+
Source : kb_sol_core_balance_changes.owner.
+
+
+
+ + + +
+
Cochez une ligne. Sans sélection, l’action accepte une seule ligne restant après le filtre local.
+
+
+
+ + + + + + + + + + + + + +
SélectionOwnerTransactionsOccurrencesOcc./txSlot minSlot maxÉtendue
+
+
+
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+
+
+

Comptes

+
Aucune requête exécutée.
+
Source : kb_sol_core_account_keys.account_key.
+
+
+
+ + + +
+
Cochez une ligne. Sans sélection, l’action accepte une seule ligne restant après le filtre local.
+
+
+
+ + + + + + + + + + + + + +
SélectionAccount keyTransactionsOccurrencesOcc./txSlot minSlot maxÉtendue
+
+
+
+
+
+
+
+
+ +
+
+
+
© 2026 SASEDEV
+
+
+
+ + + + + + diff --git a/kb-app-demo-desktop/frontend/main.html b/kb-app-demo-desktop/frontend/main.html index 8aba313..997216e 100644 --- a/kb-app-demo-desktop/frontend/main.html +++ b/kb-app-demo-desktop/frontend/main.html @@ -25,6 +25,11 @@
  • WebSocket standard
  • HTTP JSON-RPC
  • Backfill HTTP
  • +
  • +
  • SQL diagnostics
  • +
  • SQL PostgreSQL raw
  • +
  • SQL PostgreSQL core
  • +
  • SQL replay candidates
  • diff --git a/kb-app-demo-desktop/frontend/ts/demo_sql_diag.ts b/kb-app-demo-desktop/frontend/ts/demo_sql_diag.ts new file mode 100644 index 0000000..c2fb97e --- /dev/null +++ b/kb-app-demo-desktop/frontend/ts/demo_sql_diag.ts @@ -0,0 +1,60 @@ +// file: kb-app-demo-desktop/frontend/ts/demo_sql_diag.ts +// version: 2 + +import * as bootstrap from "bootstrap"; +import "simplebar"; +import ResizeObserver from "resize-observer-polyfill"; +import { invoke } from "@tauri-apps/api/core"; +import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log"; +import { jsonText, renderJsonViewer, renderTables, setText } from "./demo_sql_tables"; +import type { DemoSqlDiagPayload } from "./bindings/kb_app_demo_desktop/demo_sql_diag/DemoSqlDiagPayload"; + +(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap; +(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver; + +let latestPayload: DemoSqlDiagPayload | null = null; + +async function refreshDiagnostics(): Promise { + setText("#sqlDiagStatus", "Chargement..."); + try { + const payload = await invoke("load_demo_sql_diag"); + setText("#sqlDiagConfigPath", payload.configPath); + setText("#sqlDiagProfile", payload.activeProfileName); + setText("#sqlDiagBackend", payload.backend); + setText("#sqlDiagDsn", payload.maskedDsn); + setText("#sqlDiagSchema", payload.currentSchema ?? "—"); + setText("#sqlDiagHealth", payload.healthStatus); + setText("#sqlDiagMigration", payload.migrationStatus); + setText("#sqlDiagStatus", "OK"); + renderTables("#sqlDiagTableBody", payload.tables); + latestPayload = payload; + renderJsonViewer("#sqlDiagJson", payload); + frontendDebug("kb_app_demo.frontend.demo_sql_diag", "SQL diagnostics refreshed"); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + setText("#sqlDiagStatus", `Erreur : ${message}`); + latestPayload = null; + renderJsonViewer("#sqlDiagJson", { error: message }); + frontendError("kb_app_demo.frontend.demo_sql_diag", `SQL diagnostics loading failed: ${message}`); + } +} + +async function copyDiagnostics(): Promise { + if (latestPayload) { + await navigator.clipboard.writeText(jsonText(latestPayload)); + } +} + +document.addEventListener("DOMContentLoaded", () => { + installFrontendConsoleBridge("kb_app_demo.frontend.demo_sql_diag"); + frontendDebug("kb_app_demo.frontend.demo_sql_diag", "SQL diagnostics demo window loaded"); + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); + document.querySelector("#refreshSqlDiagButton")?.addEventListener("click", () => { + void refreshDiagnostics(); + }); + document.querySelector("#copySqlDiagButton")?.addEventListener("click", () => { + void copyDiagnostics(); + }); + void refreshDiagnostics(); +}); diff --git a/kb-app-demo-desktop/frontend/ts/demo_sql_pg_core.ts b/kb-app-demo-desktop/frontend/ts/demo_sql_pg_core.ts new file mode 100644 index 0000000..5dde4e2 --- /dev/null +++ b/kb-app-demo-desktop/frontend/ts/demo_sql_pg_core.ts @@ -0,0 +1,42 @@ +// file: kb-app-demo-desktop/frontend/ts/demo_sql_pg_core.ts +// version: 3 + +import * as bootstrap from "bootstrap"; +import "simplebar"; +import ResizeObserver from "resize-observer-polyfill"; +import { invoke } from "@tauri-apps/api/core"; +import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log"; +import { renderJsonViewer, renderTables, setText } from "./demo_sql_tables"; +import type { DemoSqlPgCorePayload } from "./bindings/kb_app_demo_desktop/demo_sql_pg_core/DemoSqlPgCorePayload"; + +(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap; +(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver; + +async function refreshCore(): Promise { + setText("#sqlCoreStatus", "Chargement..."); + try { + const payload = await invoke("load_demo_sql_pg_core"); + setText("#sqlCoreProfile", payload.activeProfileName); + setText("#sqlCoreDsn", payload.maskedDsn); + setText("#sqlCoreStatus", "OK"); + renderTables("#sqlCoreTableBody", payload.tables); + renderJsonViewer("#sqlCoreJson", payload); + frontendDebug("kb_app_demo.frontend.demo_sql_pg_core", "PostgreSQL core diagnostics refreshed"); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + setText("#sqlCoreStatus", `Erreur : ${message}`); + renderJsonViewer("#sqlCoreJson", { error: message }); + frontendError("kb_app_demo.frontend.demo_sql_pg_core", `PostgreSQL core diagnostics loading failed: ${message}`); + } +} + +document.addEventListener("DOMContentLoaded", () => { + installFrontendConsoleBridge("kb_app_demo.frontend.demo_sql_pg_core"); + frontendDebug("kb_app_demo.frontend.demo_sql_pg_core", "PostgreSQL core demo window loaded"); + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); + document.querySelector("#refreshSqlCoreButton")?.addEventListener("click", () => { + void refreshCore(); + }); + void refreshCore(); +}); diff --git a/kb-app-demo-desktop/frontend/ts/demo_sql_pg_raw.ts b/kb-app-demo-desktop/frontend/ts/demo_sql_pg_raw.ts new file mode 100644 index 0000000..5779646 --- /dev/null +++ b/kb-app-demo-desktop/frontend/ts/demo_sql_pg_raw.ts @@ -0,0 +1,42 @@ +// file: kb-app-demo-desktop/frontend/ts/demo_sql_pg_raw.ts +// version: 3 + +import * as bootstrap from "bootstrap"; +import "simplebar"; +import ResizeObserver from "resize-observer-polyfill"; +import { invoke } from "@tauri-apps/api/core"; +import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log"; +import { renderJsonViewer, renderTables, setText } from "./demo_sql_tables"; +import type { DemoSqlPgRawPayload } from "./bindings/kb_app_demo_desktop/demo_sql_pg_raw/DemoSqlPgRawPayload"; + +(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap; +(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver; + +async function refreshRaw(): Promise { + setText("#sqlRawStatus", "Chargement..."); + try { + const payload = await invoke("load_demo_sql_pg_raw"); + setText("#sqlRawProfile", payload.activeProfileName); + setText("#sqlRawDsn", payload.maskedDsn); + setText("#sqlRawStatus", "OK"); + renderTables("#sqlRawTableBody", payload.tables); + renderJsonViewer("#sqlRawJson", payload); + frontendDebug("kb_app_demo.frontend.demo_sql_pg_raw", "PostgreSQL raw diagnostics refreshed"); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + setText("#sqlRawStatus", `Erreur : ${message}`); + renderJsonViewer("#sqlRawJson", { error: message }); + frontendError("kb_app_demo.frontend.demo_sql_pg_raw", `PostgreSQL raw diagnostics loading failed: ${message}`); + } +} + +document.addEventListener("DOMContentLoaded", () => { + installFrontendConsoleBridge("kb_app_demo.frontend.demo_sql_pg_raw"); + frontendDebug("kb_app_demo.frontend.demo_sql_pg_raw", "PostgreSQL raw demo window loaded"); + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); + document.querySelector("#refreshSqlRawButton")?.addEventListener("click", () => { + void refreshRaw(); + }); + void refreshRaw(); +}); diff --git a/kb-app-demo-desktop/frontend/ts/demo_sql_replay_candidates.ts b/kb-app-demo-desktop/frontend/ts/demo_sql_replay_candidates.ts new file mode 100644 index 0000000..265da3f --- /dev/null +++ b/kb-app-demo-desktop/frontend/ts/demo_sql_replay_candidates.ts @@ -0,0 +1,800 @@ +// 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 | null = null; +let programTable: Api | null = null; +let mintTable: Api | null = null; +let ownerTable: Api | null = null; +let accountTable: Api | null = null; +let busyOperationCount = 0; +let maximumReplayCandidateLimit = 5_000; + +function element(selector: string): T { + const value = document.querySelector(selector); + if (!value) { + throw new Error(`Missing UI element: ${selector}`); + } + return value; +} + +function optionalInputValue(selector: string): string | null { + const value = element(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 `${escapeHtml(value)}`; +} + +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 `${compactText}`; + }; +} + +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 `${escapeHtml(text)}`; +} + +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 'absent'; + } + if (row.transactionFailed === true) { + return 'échec on-chain'; + } + return 'présent'; + } + 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 { + return new DataTable("#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 { + return new DataTable("#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 { + return new DataTable(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 | null { + if (kind === "mint") { + return mintTable; + } + if (kind === "owner") { + return ownerTable; + } + return accountTable; +} + +function replaceRows(table: Api, rows: T[]): void { + table.clear(); + table.rows.add(rows); + table.draw(); +} + +function resetTableState(table: Api | null): void { + if (table === null) { + return; + } + table.search(""); + table.columns().search(""); + table.rows().deselect(); + table.draw(); +} + +function selectedOrFilteredRows(table: Api): 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(table: Api, 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("#replayCandidateStatusBadge"); + badge.textContent = operationInProgress ? (busy ? message : "Chargement en cours") : "Prêt"; + badge.className = operationInProgress ? "badge text-bg-warning" : "badge text-bg-success"; + document.querySelectorAll("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 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("#transactionNewestFirstInput").checked, + }; +} + +async function resetTransactionFilters(): Promise { + element("#transactionSignatureContainsInput").value = ""; + element("#transactionMinSlotInput").value = ""; + element("#transactionMaxSlotInput").value = ""; + element("#transactionRawStateSelect").value = ""; + element("#transactionLedgerStatusSelect").value = ""; + element("#transactionProgramIdInput").value = ""; + element("#transactionProgramScopeSelect").value = "any"; + element("#transactionEntityKindSelect").value = ""; + element("#transactionEntityValueInput").value = ""; + element("#transactionLimitInput").value = "500"; + element("#transactionNewestFirstInput").checked = true; + resetTableState(transactionTable); + await loadTransactions(); +} + +async function resetProgramFilters(): Promise { + element("#knownProgramSelect").value = ""; + element("#programContainsInput").value = ""; + element("#programLimitInput").value = "500"; + resetTableState(programTable); + await loadPrograms(); +} + +async function resetEntityFilters(config: EntityViewConfig): Promise { + element(config.containsSelector).value = ""; + element(config.limitSelector).value = "500"; + resetTableState(entityTable(config.kind)); + await loadEntity(config); +} + +async function loadTransactions(): Promise { + if (transactionTable === null) { + return; + } + try { + setBusy(true, "Chargement transactions"); + const request = buildTransactionRequest(); + const rows = await invoke("load_demo_sql_replay_transactions", { request }); + replaceRows(transactionTable, rows); + element("#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("#transactionResultSummary").textContent = `Erreur : ${message}`; + frontendError(tracingTarget, `transaction candidate loading failed: ${message}`); + } finally { + setBusy(false, "Prêt"); + } +} + +async function loadPrograms(): Promise { + if (programTable === null) { + return; + } + try { + setBusy(true, "Chargement programmes"); + const request: DemoSqlReplayProgramRequest = { + programIdContains: optionalInputValue("#programContainsInput"), + limit: boundedPositiveIntegerValue("#programLimitInput"), + }; + const rows = await invoke("load_demo_sql_replay_programs", { request }); + replaceRows(programTable, rows); + element("#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("#programResultSummary").textContent = `Erreur : ${message}`; + frontendError(tracingTarget, `program summary loading failed: ${message}`); + } finally { + setBusy(false, "Prêt"); + } +} + +async function loadEntity(config: EntityViewConfig): Promise { + 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("load_demo_sql_replay_entities", { request }); + replaceRows(table, rows); + element(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(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 { + if (lines.length === 0) { + throw new Error(`Aucune ${label} à copier.`); + } + await navigator.clipboard.writeText(lines.join("\n")); + element("#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 { + if (rowCount === 0) { + throw new Error("Aucune ligne à exporter."); + } + const path = await invoke("export_demo_sql_replay_csv", { fileName, content }); + const badge = element("#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 { + 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 { + 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 { + 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("#transactionsTabButton")).show(); +} + +async function useSelectedProgram(): Promise { + if (programTable === null) { + return; + } + const row = selectedOrSingleFilteredRow(programTable, "programme"); + element("#transactionProgramIdInput").value = row.programId; + element("#transactionProgramScopeSelect").value = "any"; + showTransactionsTab(); + await loadTransactions(); +} + +async function useSelectedEntity(config: EntityViewConfig): Promise { + const table = entityTable(config.kind); + if (table === null) { + return; + } + const row = selectedOrSingleFilteredRow(table, config.label); + element("#transactionEntityKindSelect").value = config.kind; + element("#transactionEntityValueInput").value = row.entityValue; + showTransactionsTab(); + await loadTransactions(); +} + +async function openCoreExtraction(): Promise { + 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 { + const options = await invoke("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("#replayCandidateProfileBadge").textContent = `Profil ${options.activeProfileName}`; + element("#replayCandidateDsn").textContent = options.maskedDsn; + element("#replayCandidateMaximumLimit").textContent = String(options.maximumLimit); + document.querySelectorAll("input[type=number][max]").forEach(input => { + input.max = String(options.maximumLimit); + }); + populateKnownPrograms(options.knownPrograms); +} + +function populateKnownPrograms(programs: DemoSqlReplayKnownProgramOption[]): void { + const select = element("#knownProgramSelect"); + const datalist = element("#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('[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("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("#replayCandidateStatusBadge").textContent = `Erreur : ${message}`; + element("#replayCandidateStatusBadge").className = "badge text-bg-danger"; + frontendError(tracingTarget, `${action} failed: ${message}`); +} + +function installHandlers(): void { + element("#loadTransactionsButton").addEventListener("click", () => void loadTransactions()); + element("#resetTransactionsButton").addEventListener("click", () => { + void resetTransactionFilters().catch(error => reportUiError("reset transaction filters", error)); + }); + element("#loadProgramsButton").addEventListener("click", () => void loadPrograms()); + element("#resetProgramsButton").addEventListener("click", () => { + void resetProgramFilters().catch(error => reportUiError("reset program filters", error)); + }); + element("#openCoreExtractionButton").addEventListener("click", () => void openCoreExtraction()); + element("#copySignaturesButton").addEventListener("click", () => { + void copyLines(transactionRowsForExport().map(row => row.signature), "signature").catch(error => reportUiError("copy signatures", error)); + }); + element("#exportTransactionsCsvButton").addEventListener("click", () => { + void exportTransactionsCsv().catch(error => reportUiError("export transactions CSV", error)); + }); + element("#copyProgramsButton").addEventListener("click", () => { + void copyLines(programRowsForExport().map(row => row.programId), "program ID").catch(error => reportUiError("copy programs", error)); + }); + element("#exportProgramsCsvButton").addEventListener("click", () => { + void exportProgramsCsv().catch(error => reportUiError("export programs CSV", error)); + }); + element("#useSelectedProgramButton").addEventListener("click", () => { + void useSelectedProgram().catch(error => reportUiError("use selected program", error)); + }); + for (const config of entityViewConfigs) { + element(config.loadButtonSelector).addEventListener("click", () => void loadEntity(config)); + element(config.resetButtonSelector).addEventListener("click", () => { + void resetEntityFilters(config).catch(error => reportUiError(`reset ${config.pluralLabel} filters`, error)); + }); + element(config.copyButtonSelector).addEventListener("click", () => { + void copyLines(entityRowsForExport(config.kind).map(row => row.entityValue), config.label).catch(error => reportUiError(`copy ${config.pluralLabel}`, error)); + }); + element(config.exportButtonSelector).addEventListener("click", () => { + void exportEntityCsv(config).catch(error => reportUiError(`export ${config.pluralLabel} CSV`, error)); + }); + element(config.useButtonSelector).addEventListener("click", () => { + void useSelectedEntity(config).catch(error => reportUiError(`use selected ${config.label}`, error)); + }); + } + element("#knownProgramSelect").addEventListener("change", event => { + const target = event.currentTarget as HTMLSelectElement; + element("#programContainsInput").value = target.value; + }); + document.querySelectorAll('[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)); +}); diff --git a/kb-app-demo-desktop/frontend/ts/demo_sql_tables.ts b/kb-app-demo-desktop/frontend/ts/demo_sql_tables.ts new file mode 100644 index 0000000..dc4b953 --- /dev/null +++ b/kb-app-demo-desktop/frontend/ts/demo_sql_tables.ts @@ -0,0 +1,68 @@ +// file: kb-app-demo-desktop/frontend/ts/demo_sql_tables.ts +// version: 2 + +import "@andypf/json-viewer"; +import type { DemoSqlTableSnapshot } from "./bindings/kb_app_demo_desktop/demo_sql/DemoSqlTableSnapshot"; + +export function setText(selector: string, value: string): void { + const element = document.querySelector(selector); + if (element) { + element.textContent = value; + } +} + +export function renderTables(selector: string, tables: DemoSqlTableSnapshot[]): void { + const tbody = document.querySelector(selector); + if (!tbody) { + return; + } + tbody.textContent = ""; + for (const table of tables) { + const row = document.createElement("tr"); + row.appendChild(cell(table.tableName, true)); + row.appendChild(cell(table.domain, false)); + row.appendChild(cell(table.role, false)); + row.appendChild(cell(table.exists ? "oui" : "non", false)); + row.appendChild(cell(table.rowCount === null ? "—" : String(table.rowCount), false)); + row.appendChild(cell(table.minSlot === null ? "—" : String(table.minSlot), false)); + row.appendChild(cell(table.maxSlot === null ? "—" : String(table.maxSlot), false)); + row.appendChild(cell(table.latestCreatedAt ?? "—", false)); + tbody.appendChild(row); + } +} + +export function jsonText(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +export function renderJsonViewer(selector: string, value: unknown): void { + const container = document.querySelector(selector); + if (!container) { + return; + } + const viewer = document.createElement("andypf-json-viewer"); + viewer.setAttribute("indent", "2"); + viewer.setAttribute("expanded", "1"); + viewer.setAttribute("theme", "default-light"); + viewer.setAttribute("show-data-types", "true"); + viewer.setAttribute("show-toolbar", "false"); + viewer.setAttribute("expand-icon-type", "arrow"); + viewer.setAttribute("show-copy", "true"); + viewer.setAttribute("show-size", "true"); + viewer.setAttribute("expand-empty", "false"); + viewer.setAttribute("data", jsonText(value)); + container.textContent = ""; + container.appendChild(viewer); +} + +function cell(value: string, code: boolean): HTMLTableCellElement { + const td = document.createElement("td"); + if (code) { + const codeElement = document.createElement("code"); + codeElement.textContent = value; + td.appendChild(codeElement); + } else { + td.textContent = value; + } + return td; +} diff --git a/kb-app-demo-desktop/frontend/ts/main.ts b/kb-app-demo-desktop/frontend/ts/main.ts index 2c48769..d27f0e9 100644 --- a/kb-app-demo-desktop/frontend/ts/main.ts +++ b/kb-app-demo-desktop/frontend/ts/main.ts @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/frontend/ts/main.ts -// version: 2 +// version: 3 import * as bootstrap from "bootstrap"; import MarkdownIt from "markdown-it"; @@ -60,3 +60,19 @@ if (openDemoBackfillLink) { void invoke("open_demo_backfill_window"); }); } + +const sqlDemoLinks: ReadonlyArray = [ + ["openDemoSqlDiagLink", "open_demo_sql_diag_window"], + ["openDemoSqlPgRawLink", "open_demo_sql_pg_raw_window"], + ["openDemoSqlPgCoreLink", "open_demo_sql_pg_core_window"], + ["openDemoSqlReplayCandidatesLink", "open_demo_sql_replay_candidates_window"], +]; +for (const [elementId, command] of sqlDemoLinks) { + const link = document.querySelector(`#${elementId}`); + if (link) { + link.addEventListener("click", event => { + event.preventDefault(); + void invoke(command); + }); + } +} diff --git a/kb-app-demo-desktop/src/app_state.rs b/kb-app-demo-desktop/src/app_state.rs index 89014a2..46d2026 100644 --- a/kb-app-demo-desktop/src/app_state.rs +++ b/kb-app-demo-desktop/src/app_state.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/app_state.rs -// version: 3 +// version: 4 //! Shared Tauri application state and startup initialization. @@ -10,7 +10,7 @@ pub(crate) struct AppState { active_profile: kb_config::ProfileConfig, logging_guard: std::sync::Mutex, http_pool: kb_onchain_transport::HttpEndpointPool, - ws_pool: kb_onchain_transport::WsEndpointPool, + ws_pool: std::sync::Mutex>>, demo_ws_session: tokio::sync::Mutex>>, demo_backfill_running: std::sync::atomic::AtomicBool, demo_backfill_cancel_requested: std::sync::atomic::AtomicBool, @@ -37,17 +37,13 @@ impl crate::AppState { std::result::Result::Ok(pool) => pool, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let ws_pool = match kb_onchain_transport::WsEndpointPool::from_profile(&active_profile) { - std::result::Result::Ok(pool) => pool, - std::result::Result::Err(error) => return std::result::Result::Err(error), - }; return std::result::Result::Ok(crate::AppState { config_path: config_path.display().to_string(), app_config, active_profile, logging_guard: std::sync::Mutex::new(logging_guard), http_pool, - ws_pool, + ws_pool: std::sync::Mutex::new(std::option::Option::None), demo_ws_session: tokio::sync::Mutex::new(std::option::Option::None), demo_backfill_running: std::sync::atomic::AtomicBool::new(false), demo_backfill_cancel_requested: std::sync::atomic::AtomicBool::new(false), @@ -74,9 +70,28 @@ impl crate::AppState { return &self.http_pool; } - /// Returns the configured WebSocket endpoint pool. - pub(crate) fn ws_pool(&self) -> &kb_onchain_transport::WsEndpointPool { - return &self.ws_pool; + /// Returns the lazily initialized WebSocket endpoint pool. + pub(crate) fn demo_ws_pool( + &self, + ) -> kb_core::Result> { + let lock_result = self.ws_pool.lock(); + let mut guard = match lock_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(kb_core::Error::invalid_state( + "demo WebSocket pool lock is poisoned", + )); + }, + }; + if let std::option::Option::Some(pool) = guard.as_ref() { + return std::result::Result::Ok(std::sync::Arc::clone(pool)); + } + let pool = match kb_onchain_transport::WsEndpointPool::from_profile(&self.active_profile) { + std::result::Result::Ok(value) => std::sync::Arc::new(value), + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + *guard = std::option::Option::Some(std::sync::Arc::clone(&pool)); + return std::result::Result::Ok(pool); } /// Returns the persistent WebSocket demo session slot. diff --git a/kb-app-demo-desktop/src/demo_sql_common.rs b/kb-app-demo-desktop/src/demo_sql_common.rs new file mode 100644 index 0000000..2c8428c --- /dev/null +++ b/kb-app-demo-desktop/src/demo_sql_common.rs @@ -0,0 +1,363 @@ +// file: kb-app-demo-desktop/src/demo_sql_common.rs +// version: 8 + +//! Shared SQL demo helpers and serializable payloads. + +use tauri::Manager; // rust-rules: trait-import +use ts_rs::TS; // rust-rules: derive-import + +/// UI-safe table diagnostics shown by SQL demo windows. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql/DemoSqlTableSnapshot.ts" +)] +pub(crate) struct DemoSqlTableSnapshot { + /// Table name. + pub(crate) table_name: std::string::String, + /// Logical Solana domain encoded in the table name. + pub(crate) domain: std::string::String, + /// Human-readable table role. + pub(crate) role: std::string::String, + /// Whether the table currently exists. + pub(crate) exists: bool, + /// Row count when the table exists. + #[ts(type = "number | null")] + pub(crate) row_count: std::option::Option, + /// Minimum slot when available. + #[ts(type = "number | null")] + pub(crate) min_slot: std::option::Option, + /// Maximum slot when available. + #[ts(type = "number | null")] + pub(crate) max_slot: std::option::Option, + /// Latest insertion timestamp rendered by PostgreSQL when available. + pub(crate) latest_created_at: std::option::Option, +} + +/// Opens or focuses a SQL demo window. +pub(crate) fn open_sql_demo_window( + app_handle: tauri::AppHandle, + label: &str, + html_path: &str, + title: &str, +) -> std::result::Result<(), std::string::String> { + tracing::info!(target: crate::TRACING_TARGET, window = label, "open SQL demo window"); + let existing_window = app_handle.get_webview_window(label); + if let std::option::Option::Some(window) = existing_window { + let show_result = window.show(); + if let std::result::Result::Err(error) = show_result { + return std::result::Result::Err( + kb_core::Error::tauri(format!("cannot show {label} window: {error}")).to_string(), + ); + } + let focus_result = window.set_focus(); + if let std::result::Result::Err(error) = focus_result { + return std::result::Result::Err( + kb_core::Error::tauri(format!("cannot focus {label} window: {error}")).to_string(), + ); + } + return std::result::Result::Ok(()); + } + let builder = tauri::WebviewWindowBuilder::new( + &app_handle, + label, + tauri::WebviewUrl::App(html_path.into()), + ) + .title(title) + .inner_size(1220.0, 780.0) + .min_inner_size(920.0, 560.0) + .resizable(true) + .visible(true); + let build_result = builder.build(); + return match build_result { + std::result::Result::Ok(window) => { + let focus_result = window.set_focus(); + if let std::result::Result::Err(error) = focus_result { + return std::result::Result::Err( + kb_core::Error::tauri(format!("cannot focus created {label} window: {error}")) + .to_string(), + ); + } + std::result::Result::Ok(()) + }, + std::result::Result::Err(error) => std::result::Result::Err( + kb_core::Error::tauri(format!("cannot create {label} window: {error}")).to_string(), + ), + }; +} + + +/// Builds PostgreSQL options from the active profile without coupling kb-store to kb-config. +pub(crate) fn postgres_store_options_from_profile( + profile: &kb_config::ProfileConfig, + auto_initialize_schema: bool, +) -> kb_core::Result { + return kb_store::PostgresStoreOptions::new( + profile.database.postgres.url.clone(), + profile.database.postgres.max_connections, + profile.database.postgres.connect_timeout_ms, + auto_initialize_schema, + ); +} + +/// Connects to PostgreSQL without reapplying schemas initialized at application startup. +pub(crate) async fn connect_postgres_store( + profile: &kb_config::ProfileConfig, +) -> std::result::Result { + if !profile.database.enabled { + return std::result::Result::Err(std::string::String::from( + "database configuration is disabled in the active profile", + )); + } + let options_result = postgres_store_options_from_profile(profile, false); + let mut options = match options_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + options.auto_initialize_schema = false; + let store_result = kb_store::PostgresStore::connect(options).await; + return match store_result { + std::result::Result::Ok(store) => std::result::Result::Ok(store), + std::result::Result::Err(error) => std::result::Result::Err(error.to_string()), + }; +} + +/// Converts store table diagnostics to the UI payload shape. +pub(crate) fn table_snapshot_from_pg( + value: &kb_store::PostgresTableDiagnostics, +) -> crate::DemoSqlTableSnapshot { + let row_count = match &value.statistics { + std::option::Option::Some(statistics) => std::option::Option::Some(statistics.row_count), + std::option::Option::None => std::option::Option::None, + }; + let min_slot = match &value.statistics { + std::option::Option::Some(statistics) => statistics.min_slot, + std::option::Option::None => std::option::Option::None, + }; + let max_slot = match &value.statistics { + std::option::Option::Some(statistics) => statistics.max_slot, + std::option::Option::None => std::option::Option::None, + }; + let latest_created_at = match &value.statistics { + std::option::Option::Some(statistics) => statistics.latest_created_at.clone(), + std::option::Option::None => std::option::Option::None, + }; + return crate::DemoSqlTableSnapshot { + table_name: value.table_name.clone(), + domain: value.domain.clone(), + role: value.role.clone(), + exists: value.exists, + row_count, + min_slot, + max_slot, + latest_created_at, + }; +} + +/// Converts many table diagnostics to UI payloads. +pub(crate) fn table_snapshots_from_pg( + values: &[kb_store::PostgresTableDiagnostics], +) -> std::vec::Vec { + let mut output = std::vec::Vec::new(); + for value in values { + output.push(table_snapshot_from_pg(value)); + } + return output; +} + +/// Initializes PostgreSQL raw, core, decode and materialization schemas during Tauri startup. +pub(crate) async fn initialize_postgres_schema_for_startup( + state: &crate::AppState, + splash_window: &tauri::WebviewWindow, +) { + let profile = state.active_profile(); + if !profile.database.enabled { + emit_sql_startup_splash( + splash_window, + "SQL store disabled; schema initialization skipped.", + "info", + false, + ); + tracing::debug!(target: crate::TRACING_TARGET, "database is disabled; skip SQL schema initialization"); + return; + } + if profile.database.backend != "postgres" { + emit_sql_startup_splash( + splash_window, + "SQL backend is not PostgreSQL; schema initialization skipped.", + "info", + false, + ); + tracing::debug!(target: crate::TRACING_TARGET, backend = profile.database.backend.as_str(), "database backend is not postgres; skip SQL schema initialization"); + return; + } + let options_result = postgres_store_options_from_profile(profile, false); + let mut options = match options_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + let error_message = error.to_string(); + emit_sql_startup_error(splash_window, error_message.as_str()); + tracing::error!(target: crate::TRACING_TARGET, error = error_message.as_str(), "cannot build PostgreSQL store options"); + return; + }, + }; + if !options.auto_initialize_schema { + emit_sql_startup_splash( + splash_window, + "PostgreSQL schema auto-initialization disabled.", + "info", + false, + ); + let masked_dsn = options.masked_dsn(); + tracing::debug!(target: crate::TRACING_TARGET, dsn = masked_dsn.as_str(), "PostgreSQL schema auto-initialization disabled"); + return; + } + let masked_dsn = options.masked_dsn(); + options.auto_initialize_schema = false; + emit_sql_startup_splash( + splash_window, + "Checking PostgreSQL raw/core/decode tables...", + "info", + true, + ); + tracing::debug!(target: crate::TRACING_TARGET, dsn = masked_dsn.as_str(), "start PostgreSQL schema initialization"); + let store_result = kb_store::PostgresStore::connect(options).await; + let store = match store_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + let error_message = error.to_string(); + emit_sql_startup_error(splash_window, error_message.as_str()); + tracing::error!(target: crate::TRACING_TARGET, error = error_message.as_str(), "PostgreSQL connection failed during startup schema initialization"); + return; + }, + }; + let before_result = store.known_table_diagnostics().await; + let before_tables = match before_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + let error_message = error.to_string(); + emit_sql_startup_error(splash_window, error_message.as_str()); + tracing::error!(target: crate::TRACING_TARGET, error = error_message.as_str(), "cannot read table diagnostics before schema initialization"); + return; + }, + }; + let initialize_result = store.initialize_store_schema().await; + if let std::result::Result::Err(error) = initialize_result { + let error_message = error.to_string(); + emit_sql_startup_error(splash_window, error_message.as_str()); + tracing::error!(target: crate::TRACING_TARGET, error = error_message.as_str(), "PostgreSQL store schema initialization failed"); + return; + } + let after_result = store.known_table_diagnostics().await; + let after_tables = match after_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + let error_message = error.to_string(); + emit_sql_startup_error(splash_window, error_message.as_str()); + tracing::error!(target: crate::TRACING_TARGET, error = error_message.as_str(), "cannot read table diagnostics after schema initialization"); + return; + }, + }; + emit_sql_startup_table_report(splash_window, before_tables.as_slice(), after_tables.as_slice()); +} + +fn emit_sql_startup_table_report( + splash_window: &tauri::WebviewWindow, + before_tables: &[kb_store::PostgresTableDiagnostics], + after_tables: &[kb_store::PostgresTableDiagnostics], +) { + let mut created_count = 0_u32; + let mut existing_count = 0_u32; + let mut missing_count = 0_u32; + for table in after_tables { + let before_exists = table_exists_in(before_tables, table.table_name.as_str()); + if table.exists && before_exists == std::option::Option::Some(false) { + created_count += 1; + emit_sql_startup_splash( + splash_window, + format!("Created table {}", table.table_name).as_str(), + "success", + true, + ); + tracing::debug!(target: crate::TRACING_TARGET, table = table.table_name.as_str(), "PostgreSQL table created"); + } else if table.exists { + existing_count += 1; + emit_sql_startup_splash( + splash_window, + format!("Table {} already exists", table.table_name).as_str(), + "info", + true, + ); + tracing::debug!(target: crate::TRACING_TARGET, table = table.table_name.as_str(), "PostgreSQL table already exists"); + } else { + missing_count += 1; + emit_sql_startup_splash( + splash_window, + format!("Table {} is missing after initialization", table.table_name).as_str(), + "warning", + true, + ); + tracing::error!(target: crate::TRACING_TARGET, table = table.table_name.as_str(), "PostgreSQL table missing after initialization"); + } + } + emit_sql_startup_splash( + splash_window, + format!( + "PostgreSQL schema ready: {created_count} created, {existing_count} already present, {missing_count} missing." + ) + .as_str(), + schema_report_status(missing_count), + false, + ); + tracing::debug!(target: crate::TRACING_TARGET, created = created_count, existing = existing_count, missing = missing_count, "PostgreSQL schema initialization completed"); +} + +fn table_exists_in( + tables: &[kb_store::PostgresTableDiagnostics], + table_name: &str, +) -> std::option::Option { + for table in tables { + if table.table_name.as_str() == table_name { + return std::option::Option::Some(table.exists); + } + } + return std::option::Option::None; +} + +fn schema_report_status(missing_count: u32) -> &'static str { + if missing_count == 0 { + return "success"; + } + return "warning"; +} + +fn emit_sql_startup_error(splash_window: &tauri::WebviewWindow, message: &str) { + emit_sql_startup_splash( + splash_window, + format!("PostgreSQL schema initialization error: {message}").as_str(), + "danger", + false, + ); +} + +fn emit_sql_startup_splash( + splash_window: &tauri::WebviewWindow, + message: &str, + status: &str, + log_only: bool, +) { + let order = if log_only { "add_log" } else { "add_msg" }; + crate::emit_splash_order( + splash_window, + order, + std::option::Option::Some(message), + std::option::Option::Some(status), + std::option::Option::None, + ); +} + +/// Formats debug enums for UI display. +pub(crate) fn debug_status(value: T) -> std::string::String { + return format!("{value:?}"); +} diff --git a/kb-app-demo-desktop/src/demo_sql_diag.rs b/kb-app-demo-desktop/src/demo_sql_diag.rs new file mode 100644 index 0000000..f600d45 --- /dev/null +++ b/kb-app-demo-desktop/src/demo_sql_diag.rs @@ -0,0 +1,38 @@ +// file: kb-app-demo-desktop/src/demo_sql_diag.rs +// version: 3 + +//! SQL diagnostic demo commands. + +use ts_rs::TS; // rust-rules: derive-import + +/// Complete SQL diagnostic payload shown by `demo_sql_diag`. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql_diag/DemoSqlDiagPayload.ts" +)] +pub(crate) struct DemoSqlDiagPayload { + /// Configuration file path. + pub(crate) config_path: std::string::String, + /// Active profile name. + pub(crate) active_profile_name: std::string::String, + /// Configured database backend. + pub(crate) backend: std::string::String, + /// Masked PostgreSQL DSN. + pub(crate) masked_dsn: std::string::String, + /// PostgreSQL current schema. + pub(crate) current_schema: std::option::Option, + /// PostgreSQL health status. + pub(crate) health_status: std::string::String, + /// PostgreSQL health message. + pub(crate) health_message: std::option::Option, + /// Migration status. + pub(crate) migration_status: std::string::String, + /// Migration diagnostic message. + pub(crate) migration_message: std::option::Option, + /// PostgreSQL server version when available. + pub(crate) server_version: std::option::Option, + /// Known raw/core table diagnostics. + pub(crate) tables: std::vec::Vec, +} diff --git a/kb-app-demo-desktop/src/demo_sql_pg_core.rs b/kb-app-demo-desktop/src/demo_sql_pg_core.rs new file mode 100644 index 0000000..47b33f7 --- /dev/null +++ b/kb-app-demo-desktop/src/demo_sql_pg_core.rs @@ -0,0 +1,22 @@ +// file: kb-app-demo-desktop/src/demo_sql_pg_core.rs +// version: 4 + +//! PostgreSQL core store demo commands. + +use ts_rs::TS; // rust-rules: derive-import + +/// PostgreSQL core store payload shown by `demo_sql_pg_core`. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql_pg_core/DemoSqlPgCorePayload.ts" +)] +pub(crate) struct DemoSqlPgCorePayload { + /// Active profile name. + pub(crate) active_profile_name: std::string::String, + /// Masked PostgreSQL DSN. + pub(crate) masked_dsn: std::string::String, + /// Core table diagnostics. + pub(crate) tables: std::vec::Vec, +} diff --git a/kb-app-demo-desktop/src/demo_sql_pg_raw.rs b/kb-app-demo-desktop/src/demo_sql_pg_raw.rs new file mode 100644 index 0000000..e3f0dd6 --- /dev/null +++ b/kb-app-demo-desktop/src/demo_sql_pg_raw.rs @@ -0,0 +1,22 @@ +// file: kb-app-demo-desktop/src/demo_sql_pg_raw.rs +// version: 5 + +//! PostgreSQL canonical acquisition store demo commands. + +use ts_rs::TS; // rust-rules: derive-import + +/// PostgreSQL canonical acquisition payload shown by `demo_sql_pg_raw`. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql_pg_raw/DemoSqlPgRawPayload.ts" +)] +pub(crate) struct DemoSqlPgRawPayload { + /// Active profile name. + pub(crate) active_profile_name: std::string::String, + /// Masked PostgreSQL DSN. + pub(crate) masked_dsn: std::string::String, + /// Raw table diagnostics. + pub(crate) tables: std::vec::Vec, +} diff --git a/kb-app-demo-desktop/src/demo_sql_replay_candidates.rs b/kb-app-demo-desktop/src/demo_sql_replay_candidates.rs new file mode 100644 index 0000000..dddd6af --- /dev/null +++ b/kb-app-demo-desktop/src/demo_sql_replay_candidates.rs @@ -0,0 +1,409 @@ +// file: kb-app-demo-desktop/src/demo_sql_replay_candidates.rs +// version: 7 + +//! Read-only SQL replay candidate browser commands. + +use ts_rs::TS; // rust-rules: derive-import + +/// One program identifier exposed by the runtime `kb_program_ids` registry. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayKnownProgramOption.ts" +)] +pub(crate) struct DemoSqlReplayKnownProgramOption { + /// Stable lower snake case program code. + pub(crate) code: std::string::String, + /// Base58 Solana program identifier. + pub(crate) program_id: std::string::String, +} + +/// Static options for the replay candidate browser. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayOptionsPayload.ts" +)] +pub(crate) struct DemoSqlReplayOptionsPayload { + /// Active profile name. + pub(crate) active_profile_name: std::string::String, + /// Masked PostgreSQL DSN. + pub(crate) masked_dsn: std::string::String, + /// Maximum rows accepted by one query. + pub(crate) maximum_limit: u32, + /// Program identifiers enumerable from `kb_program_ids`. + pub(crate) known_programs: std::vec::Vec, +} + +/// UI request for bounded transaction replay candidates. +#[derive(Clone, Debug, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayTransactionRequest.ts" +)] +pub(crate) struct DemoSqlReplayTransactionRequest { + /// Optional partial signature search. + pub(crate) signature_contains: std::option::Option, + /// Optional inclusive minimum slot. + #[ts(type = "number | null")] + pub(crate) min_slot: std::option::Option, + /// Optional inclusive maximum slot. + #[ts(type = "number | null")] + pub(crate) max_slot: std::option::Option, + /// Optional raw processing state. + pub(crate) raw_processing_state: std::option::Option, + /// Optional latest ledger status. + pub(crate) ledger_status: std::option::Option, + /// Optional exact program id. + pub(crate) program_id: std::option::Option, + /// Program scope code: any, outer, inner or logs. + pub(crate) program_scope: std::string::String, + /// Optional entity kind: mint, owner or account_key. + pub(crate) entity_kind: std::option::Option, + /// Optional exact entity value. + pub(crate) entity_value: std::option::Option, + /// Maximum returned rows. + pub(crate) limit: u32, + /// Orders newest slots first when true. + pub(crate) newest_first: bool, +} + +/// One transaction row shown by the replay candidate browser. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayTransactionRow.ts" +)] +pub(crate) struct DemoSqlReplayTransactionRow { + /// Canonical transaction signature. + pub(crate) signature: std::string::String, + /// Transaction slot. + #[ts(type = "number")] + pub(crate) slot: i64, + /// Current raw processing state. + pub(crate) raw_processing_state: std::string::String, + /// Current raw retention state. + pub(crate) retention_state: std::string::String, + /// Whether a core transaction exists. + pub(crate) has_core_transaction: bool, + /// Core transaction failure flag when available. + pub(crate) transaction_failed: std::option::Option, + /// Latest core extraction ledger status. + pub(crate) ledger_status: std::string::String, + /// Latest processor version when available. + pub(crate) processor_version: std::option::Option, + /// Latest attempt count. + pub(crate) attempt_count: i32, + /// Number of top-level instructions. + #[ts(type = "number")] + pub(crate) outer_instruction_count: i64, + /// Number of inner instructions. + #[ts(type = "number")] + pub(crate) inner_instruction_count: i64, + /// Number of distinct top-level programs. + #[ts(type = "number")] + pub(crate) outer_program_count: i64, + /// Number of distinct inner programs. + #[ts(type = "number")] + pub(crate) inner_program_count: i64, + /// Raw row update timestamp. + pub(crate) updated_at: std::string::String, +} + +/// UI request for bounded program summaries. +#[derive(Clone, Debug, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayProgramRequest.ts" +)] +pub(crate) struct DemoSqlReplayProgramRequest { + /// Optional partial program id search. + pub(crate) program_id_contains: std::option::Option, + /// Maximum returned rows. + pub(crate) limit: u32, +} + +/// Aggregated program row shown by the replay candidate browser. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayProgramRow.ts" +)] +pub(crate) struct DemoSqlReplayProgramRow { + /// Optional stable code from `kb_program_ids`. + pub(crate) program_code: std::option::Option, + /// Program id. + pub(crate) program_id: std::string::String, + /// Number of distinct transactions. + #[ts(type = "number")] + pub(crate) transaction_count: i64, + /// Number of top-level instruction occurrences. + #[ts(type = "number")] + pub(crate) outer_instruction_count: i64, + /// Number of inner instruction occurrences. + #[ts(type = "number")] + pub(crate) inner_instruction_count: i64, + /// Number of reliably linked log occurrences. + #[ts(type = "number")] + pub(crate) log_count: i64, + /// Lowest observed slot. + #[ts(type = "number")] + pub(crate) min_slot: i64, + /// Highest observed slot. + #[ts(type = "number")] + pub(crate) max_slot: i64, +} + +/// UI request for bounded core entity summaries. +#[derive(Clone, Debug, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayEntityRequest.ts" +)] +pub(crate) struct DemoSqlReplayEntityRequest { + /// Entity kind: mint, owner or account_key. + pub(crate) entity_kind: std::string::String, + /// Optional partial entity value search. + pub(crate) entity_value_contains: std::option::Option, + /// Maximum returned rows. + pub(crate) limit: u32, +} + +/// Aggregated core entity row shown by the replay candidate browser. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_sql_replay_candidates/DemoSqlReplayEntityRow.ts" +)] +pub(crate) struct DemoSqlReplayEntityRow { + /// Stable entity kind code. + pub(crate) entity_kind: std::string::String, + /// Mint, owner or account-key address. + pub(crate) entity_value: std::string::String, + /// Number of distinct transactions. + #[ts(type = "number")] + pub(crate) transaction_count: i64, + /// Total number of core-table occurrences. + #[ts(type = "number")] + pub(crate) occurrence_count: i64, + /// Lowest observed slot. + #[ts(type = "number")] + pub(crate) min_slot: i64, + /// Highest observed slot. + #[ts(type = "number")] + pub(crate) max_slot: i64, +} + +pub(crate) fn transaction_row_from_pg( + row: kb_store::PostgresReplayTransactionCandidate, +) -> crate::DemoSqlReplayTransactionRow { + return crate::DemoSqlReplayTransactionRow { + signature: row.signature, + slot: row.slot, + raw_processing_state: row.raw_processing_state, + retention_state: row.retention_state, + has_core_transaction: row.has_core_transaction, + transaction_failed: row.transaction_failed, + ledger_status: row.ledger_status, + processor_version: row.processor_version, + attempt_count: row.attempt_count, + outer_instruction_count: row.outer_instruction_count, + inner_instruction_count: row.inner_instruction_count, + outer_program_count: row.outer_program_count, + inner_program_count: row.inner_program_count, + updated_at: row.updated_at, + }; +} + +pub(crate) fn program_row_from_pg( + row: kb_store::PostgresReplayProgramSummary, +) -> crate::DemoSqlReplayProgramRow { + let program_code = kb_program_ids::find_registered_program_id(&row.program_id) + .map(|entry| return entry.code().to_owned()); + return crate::DemoSqlReplayProgramRow { + program_code, + program_id: row.program_id, + transaction_count: row.transaction_count, + outer_instruction_count: row.outer_instruction_count, + inner_instruction_count: row.inner_instruction_count, + log_count: row.log_count, + min_slot: row.min_slot, + max_slot: row.max_slot, + }; +} + +pub(crate) fn entity_row_from_pg( + row: kb_store::PostgresReplayEntitySummary, +) -> crate::DemoSqlReplayEntityRow { + return crate::DemoSqlReplayEntityRow { + entity_kind: row.entity_kind, + entity_value: row.entity_value, + transaction_count: row.transaction_count, + occurrence_count: row.occurrence_count, + min_slot: row.min_slot, + max_slot: row.max_slot, + }; +} + +pub(crate) fn validated_csv_file_name( + file_name: &str, +) -> std::result::Result<&'static str, std::string::String> { + return match file_name { + "replay_transactions.csv" => std::result::Result::Ok("replay_transactions.csv"), + "replay_programs.csv" => std::result::Result::Ok("replay_programs.csv"), + "replay_mints.csv" => std::result::Result::Ok("replay_mints.csv"), + "replay_owners.csv" => std::result::Result::Ok("replay_owners.csv"), + "replay_account_keys.csv" => std::result::Result::Ok("replay_account_keys.csv"), + _ => std::result::Result::Err(format!("unsupported replay CSV file name: {file_name}")), + }; +} + +pub(crate) fn csv_export_directory_from_current_dir( + current_dir: &std::path::Path, +) -> std::path::PathBuf { + let current_name = current_dir.file_name().and_then(std::ffi::OsStr::to_str); + if let (std::option::Option::Some("kb_app_demo_desktop"), std::option::Option::Some(parent)) = + (current_name, current_dir.parent()) + { + return parent.join("data").join("exports_csv"); + } + return current_dir.join("data").join("exports_csv"); +} + +pub(crate) async fn available_csv_export_path( + export_dir: &std::path::Path, + file_name: &str, +) -> std::result::Result { + let direct_path = export_dir.join(file_name); + let direct_exists_result = tokio::fs::try_exists(&direct_path).await; + let direct_exists = match direct_exists_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(format!("cannot inspect CSV export path: {error}")); + }, + }; + if !direct_exists { + return std::result::Result::Ok(direct_path); + } + let stem = file_name.trim_end_matches(".csv"); + for suffix in 1_u16..=999_u16 { + let candidate = export_dir.join(format!("{stem}_{suffix}.csv")); + let exists_result = tokio::fs::try_exists(&candidate).await; + let exists = match exists_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(format!( + "cannot inspect CSV export path: {error}" + )); + }, + }; + if !exists { + return std::result::Result::Ok(candidate); + } + } + return std::result::Result::Err("cannot allocate a unique CSV export path".to_owned()); +} + +pub(crate) fn program_scope_from_code( + code: &str, +) -> std::result::Result { + return match code { + "any" => std::result::Result::Ok(kb_store::PostgresReplayProgramScope::Any), + "outer" => std::result::Result::Ok(kb_store::PostgresReplayProgramScope::Outer), + "inner" => std::result::Result::Ok(kb_store::PostgresReplayProgramScope::Inner), + "logs" => std::result::Result::Ok(kb_store::PostgresReplayProgramScope::Logs), + _ => std::result::Result::Err(format!("unsupported replay program scope: {code}")), + }; +} + +pub(crate) fn optional_entity_kind_from_code( + code: std::option::Option<&str>, +) -> std::result::Result< + std::option::Option, + std::string::String, +> { + return match code { + std::option::Option::Some(value) => { + let result = entity_kind_from_code(value); + match result { + std::result::Result::Ok(kind) => { + std::result::Result::Ok(std::option::Option::Some(kind)) + }, + std::result::Result::Err(error) => std::result::Result::Err(error), + } + }, + std::option::Option::None => std::result::Result::Ok(std::option::Option::None), + }; +} + +pub(crate) fn entity_kind_from_code( + code: &str, +) -> std::result::Result { + return match code { + "mint" => std::result::Result::Ok(kb_store::PostgresReplayEntityKind::Mint), + "owner" => std::result::Result::Ok(kb_store::PostgresReplayEntityKind::Owner), + "account_key" => std::result::Result::Ok(kb_store::PostgresReplayEntityKind::AccountKey), + _ => std::result::Result::Err(format!("unsupported replay entity kind: {code}")), + }; +} + +#[cfg(test)] +mod tests { + #[test] + fn program_scope_parser_accepts_logs() { + let result = crate::program_scope_from_code("logs"); + assert_eq!(result, std::result::Result::Ok(kb_store::PostgresReplayProgramScope::Logs)); + } + + #[test] + fn entity_kind_parser_rejects_unknown_code() { + let result = crate::entity_kind_from_code("unknown"); + assert!(result.is_err()); + } + + #[test] + fn csv_file_name_rejects_unknown_name() { + let result = crate::validated_csv_file_name("arbitrary.csv"); + assert!(result.is_err()); + } + + #[test] + fn csv_export_directory_uses_workspace_root_from_demo_crate() { + let current_dir = std::path::Path::new("/tmp/khadhroony-bot2/kb_app_demo_desktop"); + let result = crate::csv_export_directory_from_current_dir(current_dir); + assert_eq!(result, std::path::Path::new("/tmp/khadhroony-bot2/data/exports_csv")); + } + + #[test] + fn csv_file_name_accepts_split_entity_exports() { + let mint_result = crate::validated_csv_file_name("replay_mints.csv"); + let owner_result = crate::validated_csv_file_name("replay_owners.csv"); + let account_result = crate::validated_csv_file_name("replay_account_keys.csv"); + assert!(mint_result.is_ok()); + assert!(owner_result.is_ok()); + assert!(account_result.is_ok()); + } + + #[test] + fn program_row_exposes_registered_code() { + let row = kb_store::PostgresReplayProgramSummary { + program_id: kb_program_ids::SYSTEM_PROGRAM_ID.to_owned(), + transaction_count: 1, + outer_instruction_count: 1, + inner_instruction_count: 0, + log_count: 1, + min_slot: 1, + max_slot: 1, + }; + let converted = crate::program_row_from_pg(row); + assert_eq!(converted.program_code.as_deref(), std::option::Option::Some("system")); + } +} diff --git a/kb-app-demo-desktop/src/demo_ws.rs b/kb-app-demo-desktop/src/demo_ws.rs index 6ccbda4..3bb6f70 100644 --- a/kb-app-demo-desktop/src/demo_ws.rs +++ b/kb-app-demo-desktop/src/demo_ws.rs @@ -266,7 +266,11 @@ pub(crate) async fn demo_ws_connect_inner( std::result::Result::Ok(request) => request, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let selected_client = match state.ws_pool().select_client_for_role_and_method(&role, &method) { + let pool = match state.demo_ws_pool() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + let selected_client = match pool.select_client_for_role_and_method(&role, &method) { std::result::Result::Ok(client) => client, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; diff --git a/kb-app-demo-desktop/src/lib.rs b/kb-app-demo-desktop/src/lib.rs index 0588153..a846070 100644 --- a/kb-app-demo-desktop/src/lib.rs +++ b/kb-app-demo-desktop/src/lib.rs @@ -12,6 +12,11 @@ mod constants; mod demo_backfill; mod demo_http; mod demo_ws; +mod demo_sql_common; +mod demo_sql_diag; +mod demo_sql_pg_core; +mod demo_sql_pg_raw; +mod demo_sql_replay_candidates; mod frontend_log; mod main_window; mod splash; @@ -90,6 +95,60 @@ pub(crate) use self::demo_ws::demo_ws_status_inner; pub(crate) use self::demo_ws::demo_ws_unsubscribe_inner; /// Disconnects the application WebSocket session during shutdown. pub(crate) use self::demo_ws::disconnect_demo_ws_app_state; +/// UI-safe SQL table diagnostic snapshot. +pub(crate) use self::demo_sql_common::DemoSqlTableSnapshot; +/// Connects to the configured PostgreSQL store. +pub(crate) use self::demo_sql_common::connect_postgres_store; +/// Formats diagnostic statuses. +pub(crate) use self::demo_sql_common::debug_status; +/// Opens or focuses one SQL demo window. +pub(crate) use self::demo_sql_common::open_sql_demo_window; +/// Builds PostgreSQL options from the active profile. +pub(crate) use self::demo_sql_common::postgres_store_options_from_profile; +/// Converts one PostgreSQL table diagnostic. +pub(crate) use self::demo_sql_common::table_snapshot_from_pg; +/// Converts PostgreSQL table diagnostics. +pub(crate) use self::demo_sql_common::table_snapshots_from_pg; +/// SQL diagnostic payload. +pub(crate) use self::demo_sql_diag::DemoSqlDiagPayload; +/// PostgreSQL core-table payload. +pub(crate) use self::demo_sql_pg_core::DemoSqlPgCorePayload; +/// PostgreSQL raw-table payload. +pub(crate) use self::demo_sql_pg_raw::DemoSqlPgRawPayload; +/// SQL replay entity request. +pub(crate) use self::demo_sql_replay_candidates::DemoSqlReplayEntityRequest; +/// SQL replay entity row. +pub(crate) use self::demo_sql_replay_candidates::DemoSqlReplayEntityRow; +/// Known program option. +pub(crate) use self::demo_sql_replay_candidates::DemoSqlReplayKnownProgramOption; +/// SQL replay options payload. +pub(crate) use self::demo_sql_replay_candidates::DemoSqlReplayOptionsPayload; +/// SQL replay program request. +pub(crate) use self::demo_sql_replay_candidates::DemoSqlReplayProgramRequest; +/// SQL replay program row. +pub(crate) use self::demo_sql_replay_candidates::DemoSqlReplayProgramRow; +/// SQL replay transaction request. +pub(crate) use self::demo_sql_replay_candidates::DemoSqlReplayTransactionRequest; +/// SQL replay transaction row. +pub(crate) use self::demo_sql_replay_candidates::DemoSqlReplayTransactionRow; +/// Resolves an available CSV path. +pub(crate) use self::demo_sql_replay_candidates::available_csv_export_path; +/// Resolves the CSV export directory. +pub(crate) use self::demo_sql_replay_candidates::csv_export_directory_from_current_dir; +/// Parses an entity-kind code. +pub(crate) use self::demo_sql_replay_candidates::entity_kind_from_code; +/// Converts a PostgreSQL entity row. +pub(crate) use self::demo_sql_replay_candidates::entity_row_from_pg; +/// Parses an optional entity-kind code. +pub(crate) use self::demo_sql_replay_candidates::optional_entity_kind_from_code; +/// Converts a PostgreSQL program row. +pub(crate) use self::demo_sql_replay_candidates::program_row_from_pg; +/// Parses a program-scope code. +pub(crate) use self::demo_sql_replay_candidates::program_scope_from_code; +/// Converts a PostgreSQL transaction row. +pub(crate) use self::demo_sql_replay_candidates::transaction_row_from_pg; +/// Validates a CSV filename. +pub(crate) use self::demo_sql_replay_candidates::validated_csv_file_name; /// Frontend logging payload. pub(crate) use self::frontend_log::FrontendLogPayload; /// Emits one normalized frontend log event. diff --git a/kb-app-demo-desktop/src/tauri.rs b/kb-app-demo-desktop/src/tauri.rs index cb0a973..fb3a709 100644 --- a/kb-app-demo-desktop/src/tauri.rs +++ b/kb-app-demo-desktop/src/tauri.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/tauri.rs -// version: 6 +// version: 7 //! Tauri runtime assembly and private command wrappers. @@ -52,8 +52,33 @@ pub fn run() -> kb_core::Result<()> { demo_ws_connect, demo_ws_unsubscribe, demo_ws_disconnect, + open_demo_sql_diag_window, + load_demo_sql_diag, + open_demo_sql_pg_raw_window, + load_demo_sql_pg_raw, + open_demo_sql_pg_core_window, + load_demo_sql_pg_core, + open_demo_sql_replay_candidates_window, + demo_sql_replay_options, + load_demo_sql_replay_transactions, + load_demo_sql_replay_programs, + load_demo_sql_replay_entities, + export_demo_sql_replay_csv, ]); builder = builder.plugin(tracing_builder.build::()); + builder = builder.on_window_event(|window, event| { + if window.label() != "demo_ws" { + return; + } + if !matches!(event, tauri::WindowEvent::Destroyed) { + return; + } + let app_handle = window.app_handle().clone(); + tauri::async_runtime::spawn(async move { + let state = app_handle.state::(); + crate::disconnect_demo_ws_app_state(&state, false).await; + }); + }); builder = builder.setup(|app| { let splash_window = match app.get_webview_window("splash") { std::option::Option::Some(window) => window, @@ -96,13 +121,6 @@ pub fn run() -> kb_core::Result<()> { std::option::Option::None, std::option::Option::None, ); - crate::emit_splash_order( - &splash_window, - "add_log", - std::option::Option::Some("Pool WebSocket initialisé"), - std::option::Option::None, - std::option::Option::None, - ); crate::emit_splash_order( &splash_window, "fadein", @@ -434,7 +452,11 @@ fn open_demo_ws_window( fn demo_ws_list_pool_clients( state: tauri::State<'_, crate::AppState>, ) -> std::result::Result, std::string::String> { - return std::result::Result::Ok(state.ws_pool().snapshot()); + let pool = match state.demo_ws_pool() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + return std::result::Result::Ok(pool.snapshot()); } /// Lists selectable WebSocket roles and methods for the demo UI. @@ -443,7 +465,10 @@ fn demo_ws_options( state: tauri::State<'_, crate::AppState>, ) -> std::result::Result { return std::result::Result::Ok(crate::DemoWsOptionsPayload { - roles: crate::build_ws_role_options(state.ws_pool().snapshot()), + roles: crate::build_ws_role_options(match state.demo_ws_pool() { + std::result::Result::Ok(pool) => pool.snapshot(), + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }), methods: crate::build_ws_method_options(), }); } @@ -488,3 +513,338 @@ async fn demo_ws_disconnect( ) -> std::result::Result { return crate::demo_ws_disconnect_inner(state.inner()).await; } + +/// Opens or focuses the SQL diagnostic demo window. +#[tauri::command] +fn open_demo_sql_diag_window( + app_handle: tauri::AppHandle, +) -> std::result::Result<(), std::string::String> { + return crate::open_sql_demo_window( + app_handle, + "demo_sql_diag", + "demo_sql_diag.html", + "Khadhroony Bot3 - SQL diagnostics", + ); +} + +/// Loads read-only SQL diagnostics from the active profile. +#[tauri::command] +#[allow(clippy::question_mark_used)] +async fn load_demo_sql_diag( + state: tauri::State<'_, crate::AppState>, +) -> std::result::Result { + let profile = state.active_profile().clone(); + let store_result = crate::connect_postgres_store(&profile).await; + let store = match store_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let diagnostics_result = store.backend_diagnostics().await; + let diagnostics = match diagnostics_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + let tables_result = store.known_table_diagnostics().await; + let tables = match tables_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + return std::result::Result::Ok(crate::DemoSqlDiagPayload { + config_path: state.config_path().to_string(), + active_profile_name: profile.name.clone(), + backend: profile.database.backend.clone(), + masked_dsn: store.options().masked_dsn(), + current_schema: diagnostics.descriptor.current_schema, + health_status: crate::debug_status(diagnostics.health.status), + health_message: diagnostics.health.message, + migration_status: crate::debug_status(diagnostics.migrations.status), + migration_message: diagnostics.migrations.message, + server_version: diagnostics.server_version, + tables: crate::table_snapshots_from_pg(tables.as_slice()), + }); +} + +/// Opens or focuses the PostgreSQL canonical acquisition demo window. +#[tauri::command] +fn open_demo_sql_pg_raw_window( + app_handle: tauri::AppHandle, +) -> std::result::Result<(), std::string::String> { + return crate::open_sql_demo_window( + app_handle, + "demo_sql_pg_raw", + "demo_sql_pg_raw.html", + "Khadhroony Bot3 - PostgreSQL canonical acquisition", + ); +} + +/// Loads read-only raw table diagnostics from PostgreSQL. +#[tauri::command] +#[allow(clippy::question_mark_used)] +async fn load_demo_sql_pg_raw( + state: tauri::State<'_, crate::AppState>, +) -> std::result::Result { + let profile = state.active_profile().clone(); + let store_result = crate::connect_postgres_store(&profile).await; + let store = match store_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let tables_result = store.raw_table_diagnostics().await; + let tables = match tables_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + return std::result::Result::Ok(crate::DemoSqlPgRawPayload { + active_profile_name: profile.name.clone(), + masked_dsn: store.options().masked_dsn(), + tables: crate::table_snapshots_from_pg(tables.as_slice()), + }); +} + +/// Opens or focuses the PostgreSQL core store demo window. +#[tauri::command] +fn open_demo_sql_pg_core_window( + app_handle: tauri::AppHandle, +) -> std::result::Result<(), std::string::String> { + return crate::open_sql_demo_window( + app_handle, + "demo_sql_pg_core", + "demo_sql_pg_core.html", + "Khadhroony Bot3 - PostgreSQL core store", + ); +} + +/// Loads read-only core table diagnostics from PostgreSQL. +#[tauri::command] +#[allow(clippy::question_mark_used)] +async fn load_demo_sql_pg_core( + state: tauri::State<'_, crate::AppState>, +) -> std::result::Result { + let profile = state.active_profile().clone(); + let store_result = crate::connect_postgres_store(&profile).await; + let store = match store_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let tables_result = store.core_table_diagnostics().await; + let tables = match tables_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + return std::result::Result::Ok(crate::DemoSqlPgCorePayload { + active_profile_name: profile.name.clone(), + masked_dsn: store.options().masked_dsn(), + tables: crate::table_snapshots_from_pg(tables.as_slice()), + }); +} + +/// Opens or focuses the SQL replay candidate browser. +#[tauri::command] +fn open_demo_sql_replay_candidates_window( + app_handle: tauri::AppHandle, +) -> std::result::Result<(), std::string::String> { + return crate::open_sql_demo_window( + app_handle, + "demo_sql_replay_candidates", + "demo_sql_replay_candidates.html", + "Khadhroony Bot3 - SQL replay candidates", + ); +} + +/// Writes a bounded replay-candidate CSV file into `./data/exports_csv/`. +#[tauri::command] +#[allow(clippy::question_mark_used)] +async fn export_demo_sql_replay_csv( + file_name: std::string::String, + content: std::string::String, +) -> std::result::Result { + if content.is_empty() { + return std::result::Result::Err("CSV export content must not be empty".to_owned()); + } + if content.len() > 16 * 1024 * 1024 { + return std::result::Result::Err("CSV export content exceeds 16 MiB".to_owned()); + } + let normalized_name_result = crate::validated_csv_file_name(&file_name); + let normalized_name = match normalized_name_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let current_dir_result = std::env::current_dir(); + let current_dir = match current_dir_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(format!("cannot resolve current directory: {error}")); + }, + }; + let export_dir = crate::csv_export_directory_from_current_dir(¤t_dir); + let create_result = tokio::fs::create_dir_all(&export_dir).await; + if let std::result::Result::Err(error) = create_result { + return std::result::Result::Err(format!("cannot create CSV export directory: {error}")); + } + let export_path_result = crate::available_csv_export_path(&export_dir, normalized_name).await; + let export_path = match export_path_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut bytes = std::vec::Vec::with_capacity(content.len() + 3); + bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); + bytes.extend_from_slice(content.as_bytes()); + let write_result = tokio::fs::write(&export_path, bytes).await; + if let std::result::Result::Err(error) = write_result { + return std::result::Result::Err(format!("cannot write CSV export: {error}")); + } + let display_path = export_path.to_string_lossy().into_owned(); + tracing::info!(target: crate::TRACING_TARGET, path = %display_path, "CSV replay candidate export written"); + return std::result::Result::Ok(display_path); +} + +/// Loads static browser options from the active PostgreSQL profile. +#[tauri::command] +#[allow(clippy::question_mark_used)] +async fn demo_sql_replay_options( + state: tauri::State<'_, crate::AppState>, +) -> std::result::Result { + let profile = state.active_profile().clone(); + let options_result = crate::postgres_store_options_from_profile(&profile, false); + let options = match options_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + let mut known_programs = + std::vec::Vec::with_capacity(kb_program_ids::registered_program_ids().len()); + for entry in kb_program_ids::registered_program_ids() { + known_programs.push(crate::DemoSqlReplayKnownProgramOption { + code: entry.code().to_owned(), + program_id: entry.program_id().to_owned(), + }); + } + return std::result::Result::Ok(crate::DemoSqlReplayOptionsPayload { + active_profile_name: profile.name, + masked_dsn: options.masked_dsn(), + maximum_limit: kb_store::MAX_REPLAY_CANDIDATE_ROWS, + known_programs, + }); +} + +/// Loads bounded transaction replay candidates from PostgreSQL. +#[tauri::command] +#[allow(clippy::question_mark_used)] +async fn load_demo_sql_replay_transactions( + state: tauri::State<'_, crate::AppState>, + request: crate::DemoSqlReplayTransactionRequest, +) -> std::result::Result, std::string::String> { + let program_scope_result = crate::program_scope_from_code(request.program_scope.as_str()); + let program_scope = match program_scope_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let entity_kind_result = crate::optional_entity_kind_from_code(request.entity_kind.as_deref()); + let entity_kind = match entity_kind_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let filter_result = kb_store::PostgresReplayTransactionFilter::new( + request.signature_contains, + request.min_slot, + request.max_slot, + request.raw_processing_state, + request.ledger_status, + request.program_id, + program_scope, + entity_kind, + request.entity_value, + request.limit, + request.newest_first, + ); + let filter = match filter_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + let store_result = crate::connect_postgres_store(state.active_profile()).await; + let store = match store_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let rows_result = store.replay_transaction_candidates(&filter).await; + let rows = match rows_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + tracing::debug!(target: crate::TRACING_TARGET, rows = rows.len(), "loaded replay transaction candidates"); + let mut output = std::vec::Vec::with_capacity(rows.len()); + for row in rows { + output.push(crate::transaction_row_from_pg(row)); + } + return std::result::Result::Ok(output); +} + +/// Loads bounded program summaries from PostgreSQL. +#[tauri::command] +#[allow(clippy::question_mark_used)] +async fn load_demo_sql_replay_programs( + state: tauri::State<'_, crate::AppState>, + request: crate::DemoSqlReplayProgramRequest, +) -> std::result::Result, std::string::String> { + let filter_result = + kb_store::PostgresReplayProgramFilter::new(request.program_id_contains, request.limit); + let filter = match filter_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + let store_result = crate::connect_postgres_store(state.active_profile()).await; + let store = match store_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let rows_result = store.replay_program_summaries(&filter).await; + let rows = match rows_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + tracing::debug!(target: crate::TRACING_TARGET, rows = rows.len(), "loaded replay program summaries"); + let mut output = std::vec::Vec::with_capacity(rows.len()); + for row in rows { + output.push(crate::program_row_from_pg(row)); + } + return std::result::Result::Ok(output); +} + +/// Loads bounded mint, owner or account-key summaries from PostgreSQL core tables. +#[tauri::command] +#[allow(clippy::question_mark_used)] +async fn load_demo_sql_replay_entities( + state: tauri::State<'_, crate::AppState>, + request: crate::DemoSqlReplayEntityRequest, +) -> std::result::Result, std::string::String> { + let entity_kind_result = crate::entity_kind_from_code(request.entity_kind.as_str()); + let entity_kind = match entity_kind_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let filter_result = kb_store::PostgresReplayEntityFilter::new( + entity_kind, + request.entity_value_contains, + request.limit, + ); + let filter = match filter_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + let store_result = crate::connect_postgres_store(state.active_profile()).await; + let store = match store_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let rows_result = store.replay_entity_summaries(&filter).await; + let rows = match rows_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + tracing::debug!(target: crate::TRACING_TARGET, rows = rows.len(), "loaded replay entity summaries"); + let mut output = std::vec::Vec::with_capacity(rows.len()); + for row in rows { + output.push(crate::entity_row_from_pg(row)); + } + return std::result::Result::Ok(output); +} + diff --git a/kb-app-demo-desktop/vite.config.ts b/kb-app-demo-desktop/vite.config.ts index cbb4a50..1c55d18 100644 --- a/kb-app-demo-desktop/vite.config.ts +++ b/kb-app-demo-desktop/vite.config.ts @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/vite.config.ts -// version: 2 +// version: 3 import { defineConfig, normalizePath } from "vite"; import { NodePackageImporter } from "sass-embedded"; @@ -22,7 +22,14 @@ export default defineConfig(() => ({ rollupOptions: { input: { "splash": normalizePath(resolve(__dirname, 'frontend/splash.html')), - "main": normalizePath(resolve(__dirname, 'frontend/main.html')) + "main": normalizePath(resolve(__dirname, 'frontend/main.html')), + "demo_backfill": normalizePath(resolve(__dirname, 'frontend/demo_backfill.html')), + "demo_http": normalizePath(resolve(__dirname, 'frontend/demo_http.html')), + "demo_ws": normalizePath(resolve(__dirname, 'frontend/demo_ws.html')), + "demo_sql_diag": normalizePath(resolve(__dirname, 'frontend/demo_sql_diag.html')), + "demo_sql_pg_raw": normalizePath(resolve(__dirname, 'frontend/demo_sql_pg_raw.html')), + "demo_sql_pg_core": normalizePath(resolve(__dirname, 'frontend/demo_sql_pg_core.html')), + "demo_sql_replay_candidates": normalizePath(resolve(__dirname, 'frontend/demo_sql_replay_candidates.html')), }, output: { entryFileNames: 'js/[name]-[hash].js',