0.1.0
This commit is contained in:
68
migration/khadhroony-bot2-reference/kb_app_demo/Cargo.toml
Normal file
68
migration/khadhroony-bot2-reference/kb_app_demo/Cargo.toml
Normal file
@@ -0,0 +1,68 @@
|
||||
# file: kb_app_demo/Cargo.toml
|
||||
# version: 26
|
||||
|
||||
[package]
|
||||
name = "kb_app_demo"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant, but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "kb_app_demo_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono.workspace = true
|
||||
fs2.workspace = true
|
||||
kb_config = { path = "../kb_config" }
|
||||
kb_core = { path = "../kb_core" }
|
||||
kb_decoder_api = { path = "../kb_decoder_api" }
|
||||
kb_decoder_solana_core = { path = "../kb_decoder_solana_core" }
|
||||
kb_decoder_spl_associated_token_account = { path = "../kb_decoder_spl_associated_token_account" }
|
||||
kb_decoder_spl_elgamal_registry = { path = "../kb_decoder_spl_elgamal_registry" }
|
||||
kb_decoder_spl_memo = { path = "../kb_decoder_spl_memo" }
|
||||
kb_decoder_spl_token = { path = "../kb_decoder_spl_token" }
|
||||
kb_decoder_spl_token_2022 = { path = "../kb_decoder_spl_token_2022" }
|
||||
kb_execution_api = { path = "../kb_execution_api" }
|
||||
kb_executor_spl_associated_token_account = { path = "../kb_executor_spl_associated_token_account" }
|
||||
kb_executor_spl_elgamal_registry = { path = "../kb_executor_spl_elgamal_registry" }
|
||||
kb_executor_spl_token = { path = "../kb_executor_spl_token" }
|
||||
kb_executor_spl_token_2022 = { path = "../kb_executor_spl_token_2022" }
|
||||
kb_logging = { path = "../kb_logging" }
|
||||
kb_materializer_admin = { path = "../kb_materializer_admin" }
|
||||
kb_materializer_api = { path = "../kb_materializer_api" }
|
||||
kb_materializer_compliance_audit = { path = "../kb_materializer_compliance_audit" }
|
||||
kb_materializer_fees = { path = "../kb_materializer_fees" }
|
||||
kb_materializer_lifecycle = { path = "../kb_materializer_lifecycle" }
|
||||
kb_materializer_risk = { path = "../kb_materializer_risk" }
|
||||
kb_materializer_staking = { path = "../kb_materializer_staking" }
|
||||
kb_materializer_token_accounts = { path = "../kb_materializer_token_accounts" }
|
||||
kb_materializer_transaction_annotations = { path = "../kb_materializer_transaction_annotations" }
|
||||
kb_model = { path = "../kb_model" }
|
||||
kb_pipeline = { path = "../kb_pipeline" }
|
||||
kb_program_ids = { path = "../kb_program_ids" }
|
||||
kb_store_core = { path = "../kb_store_core" }
|
||||
kb_store_pg = { path = "../kb_store_pg" }
|
||||
kb_rpc = { path = "../kb_rpc" }
|
||||
kb_wallet = { path = "../kb_wallet" }
|
||||
rustls.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
solana-pubkey.workspace = true
|
||||
spl-associated-token-account-interface.workspace = true
|
||||
tauri.workspace = true
|
||||
tauri-plugin-tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
ts-rs.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
144
migration/khadhroony-bot2-reference/kb_app_demo/README.md
Normal file
144
migration/khadhroony-bot2-reference/kb_app_demo/README.md
Normal file
@@ -0,0 +1,144 @@
|
||||
<!-- file: kb_app_demo/README.md -->
|
||||
<!-- version: 35 -->
|
||||
|
||||
# kb_app_demo
|
||||
|
||||
Application Tauri de démonstration pour valider progressivement les briques runtime de `khadhroony-bot2`.
|
||||
|
||||
## Fenêtres disponibles
|
||||
|
||||
- `main` : accueil et rendu du README workspace ;
|
||||
- `demo_config` : configuration active, profil actif et schéma JSON ;
|
||||
- `demo_http` : test manuel du pool HTTP JSON-RPC standard ;
|
||||
- `demo_ws` : test manuel des neuf subscriptions standard via la session persistante typée de `kb_rpc` ;
|
||||
- `demo_backfill` : campagnes HTTP bornées par signatures, programme, token ou pool ;
|
||||
- `demo_core_extraction` : extraction canonique vers core par signatures, pending, plage de slots ou program id ;
|
||||
- `demo_decode_replay` : replay commun de décodage et matérialisation par signatures, programme, état et instruction path ;
|
||||
- `demo_execution_solana_core` : fenêtre unique de simulation et d’exécution System transfer, SPL Memo v4, SPL Token `TransferChecked` ou création ATA sur Devnet, avec wallet persistant, confirmation opérateur et replay post-exécution ;
|
||||
- `demo_sql_diag`, `demo_sql_pg_raw`, `demo_sql_pg_core` : diagnostics PostgreSQL ;
|
||||
- `demo_sql_replay_candidates` : filtres read-only et exports de signatures, programmes et entités ;
|
||||
- `splash` : écran de chargement.
|
||||
|
||||
## Commande de développement
|
||||
|
||||
```bash
|
||||
cargo tauri dev -c kb_app_demo/tauri.conf.json
|
||||
```
|
||||
|
||||
|
||||
## Démo WebSocket `0.4.2-pre.025`
|
||||
|
||||
`demo_ws` est désormais un adaptateur Tauri mince vers `kb_rpc::WsSession`. La fenêtre sélectionne un rôle et une méthode, convertit les champs UI en `StandardWsRequest`, affiche les souscriptions actives et relaie les événements typés avec une limitation de débit vers la WebView. Elle ne gère plus directement la socket, les IDs JSON-RPC, les réponses subscribe/unsubscribe, les timeouts, la reconnexion ou le réabonnement.
|
||||
|
||||
La même connexion reste partagée entre les subscriptions compatibles d’un endpoint. Les trois méthodes instables ne sont disponibles que lorsque la configuration de l’endpoint annonce explicitement leur request kind. Si le nœud rejette une de ces méthodes comme absente ou non activée, `kb_rpc` désactive automatiquement sa capacité pour la session. Les IDs distants affichés sont actualisés après un réabonnement ; l’identifiant local stable reste interne à la bibliothèque.
|
||||
|
||||
Les payloads Tauri actifs utilisent des nombres JSON et non des `BigInt`. Le frontend refuse tout identifiant d’unsubscribe qui n’est pas un entier sûr JavaScript avant d’appeler `invoke`.
|
||||
|
||||
## Démo backfill `0.3.3`
|
||||
|
||||
`demo_backfill` construit des requêtes bornées vers le moteur réutilisable de `kb_pipeline`, affiche les événements de progression et permet un arrêt coopératif. La fenêtre n’implémente elle-même ni pagination RPC ni persistance.
|
||||
|
||||
## Démo extraction core `0.3.4`
|
||||
|
||||
`demo_core_extraction` réutilise le template global et des accordéons Bootstrap 5. Elle expose :
|
||||
|
||||
- signatures explicites ;
|
||||
- lot raw en état `received` ;
|
||||
- plage inclusive de slots ;
|
||||
- replay par program id déjà présent dans core ;
|
||||
- limite, concurrence, force replay par signatures et mode explicite « toutes les signatures » ;
|
||||
- version du processor ;
|
||||
- journal borné, résumé JSON et arrêt coopératif.
|
||||
|
||||
La logique profonde reste dans `kb_pipeline`. Tauri ne construit que la requête, connecte `kb_store_pg` et relaie les événements.
|
||||
|
||||
## Démo candidats replay SQL `0.3.4`
|
||||
|
||||
`demo_sql_replay_candidates` utilise DataTables avec les intégrations Bootstrap 5 et Select. Elle expose cinq tableaux indépendants :
|
||||
|
||||
- transactions/signatures filtrables par raw, ledger, slots, programme et entité ;
|
||||
- programmes agrégés depuis outer instructions, inner instructions et logs reliés ;
|
||||
- mints issus des balance changes core ;
|
||||
- owners issus des balance changes core ;
|
||||
- account keys issus des comptes résolus core.
|
||||
|
||||
Les requêtes PostgreSQL sont statiques, paramétrées et bornées. Chaque ligne possède une case à cocher et le header permet de sélectionner ou désélectionner toutes les lignes filtrées. DataTables affiche dix lignes par défaut. Les lignes sélectionnées, ou à défaut les lignes correspondant au filtre local, peuvent être copiées sous forme multiligne ou exportées en CSV. Le frontend produit un CSV UTF-8 avec BOM et séparateur point-virgule, puis une commande Rust l’écrit sous `./data/exports_csv/` sans écraser un fichier existant. LibreOffice Calc peut conserver son dialogue d’import de texte, car CSV ne transporte pas de métadonnées normalisées d’encodage ou de séparateur. Le sélecteur de programmes connus est alimenté par le registre énumérable de `kb_program_ids`. La copie des signatures est directement réutilisable dans `demo_core_extraction`.
|
||||
|
||||
Les tableaux mints, owners et comptes exposent les transactions distinctes, les occurrences core, le ratio occurrences par transaction, les slots minimum et maximum et leur étendue. Les headers documentent ces métriques avec des tooltips Bootstrap 5.
|
||||
|
||||
Les cartes de filtres possèdent un bouton `Réinitialiser` qui restaure leurs valeurs par défaut, efface le filtre local DataTables, annule la sélection courante et recharge le tableau. Les limites sont contrôlées contre `maximumLimit` avant l’invocation Tauri ; une valeur supérieure à la borne backend n’est plus envoyée à PostgreSQL. Les signatures, program IDs, mints, owners et account keys sont tronqués visuellement pour conserver des tableaux lisibles. Leur valeur complète reste accessible par tooltip et par une action de copie individuelle directement dans la cellule, en complément des copies multiligne.
|
||||
|
||||
## Politique future pour les transactions Solana échouées
|
||||
|
||||
Les futures étapes de décodage doivent traiter les transactions confirmées dont `meta.err` est renseigné. Leur message, leurs instructions, leurs inner instructions disponibles, leurs logs, leur erreur et leur consommation de ressources restent des données d’observation importantes. Elles doivent être décodées avec un statut explicite d’échec on-chain et ne doivent pas être confondues avec un échec du pipeline.
|
||||
|
||||
La matérialisation doit rester prudente : aucune mutation métier supposée réussie ne doit être produite à partir d’une transaction atomiquement annulée. Les événements d’intention, d’échec, d’audit, de diagnostic et les frais réellement prélevés peuvent en revanche être conservés dans des familles dédiées.
|
||||
|
||||
## Démo replay decode `0.4.0`
|
||||
|
||||
`demo_decode_replay` consomme les inputs contextualisés déjà présents dans core. La fenêtre expose les signatures copiées depuis `demo_sql_replay_candidates`, le program ID, les états d’instruction, les instruction paths, les décodeurs disponibles, leur version, la concurrence bornée, le force replay sécurisé, l’arrêt coopératif et la matérialisation optionnelle uniquement lorsqu’un matérialiseur est enregistré. Elle affiche les événements de progression, le résumé par processor ainsi que des diagnostics read-only sur les tables decode, mat, ledger et couverture. Elle propose aussi un journal borné des annotations de transaction commitées, filtrable par signature ; cette vue typée ne donne pas accès au JSON libre ni aux autres familles matérialisées.
|
||||
|
||||
Pour valider Memo sur un corpus réel, l’opérateur enchaîne `demo_backfill`, `demo_core_extraction`, la sélection dans `demo_sql_replay_candidates`, puis `demo_decode_replay` avec `spl_memo` et l’option de matérialisation. Le journal d’annotations doit alors afficher la signature et le path attendus. Un second replay sans force doit être compté comme `skipped` et ne doit créer aucune annotation supplémentaire.
|
||||
|
||||
À partir de `pre.020`, la démo enregistre trois matérialiseurs natifs : lifecycle, admin et compliance audit. `0.4.3-pre.002` ajoute staking et les annotations de transaction Memo. Le pipeline sélectionne chacun par surface/entrée exacte ; une observation non pertinente ne crée ni ledger ni refus artificiel.
|
||||
|
||||
La première implémentation concrète est le classifieur `solana_core_native`, qui reconnaît toutes les surfaces natives enregistrées et les classe `unsupported` tant que leur décodage maximal n’est pas implémenté en `0.4.1`. Cette classification volontaire évite tout faux événement tout en alimentant la couverture observée.
|
||||
|
||||
## Démo exécution Solana Core `0.4.2-pre.010`
|
||||
|
||||
`demo_execution_solana_core` est un adaptateur Tauri mince vers `kb_pipeline::execute_devnet_system_transfer`. La fenêtre ne propose que les profils Devnet dont le wallet temporaire est activé et persistant. Elle permet :
|
||||
|
||||
- de générer une adresse destinataire jetable sans exposer sa clé privée ;
|
||||
- de simuler le message exact sans signature ni envoi ;
|
||||
- de signer et envoyer après confirmation opérateur explicite ;
|
||||
- d’afficher le plan, les comptes et signataires requis, les plafonds, l’erreur et les logs de simulation ;
|
||||
- de suivre l’envoi, la confirmation, l’insertion canonique, l’extraction core et le decode replay ciblé ;
|
||||
- de demander une annulation coopérative entre deux étapes.
|
||||
|
||||
Le backend vérifie l’existence du destinataire et refuse un transfert inférieur au minimum rent-exempt lorsque l’adresse est neuve. Les secrets restent confinés dans `kb_wallet`; les payloads TS-rs ne contiennent que des clés publiques et des diagnostics. Les opérations administratives natives plus dangereuses restent hors de cette fenêtre même lorsqu’elles seront disponibles dans les bibliothèques.
|
||||
|
||||
`0.4.3-pre.005` ajoute dans cette même fenêtre un panneau SPL Memo v4, sans nouvelle WebView. Il appelle `kb_pipeline::execute_devnet_memo`, reste en simulation seule par défaut et réutilise le verrou, le profil et la confirmation opérateur du parcours System. Après un envoi autorisé, le résumé affiche la signature, la confirmation, l’insertion canonique, l’extraction core, le decode replay, le nombre d’annotations `transaction_annotation` et la validation du second replay idempotent. Les soldes et frais Memo sont sérialisés en chaînes décimales afin de préserver leur exactitude aux frontières JSON Tauri.
|
||||
|
||||
La validation Tauri réelle a couvert le refus d’envoi sans confirmation, une simulation Memo réussie et trois envois v4 confirmés. Chaque résumé terminal a indiqué `annotationCount=1` et `idempotenceValidated=true`. Le clic accidentel sur le bouton System sans destinataire produit indépendamment le refus attendu « Le destinataire est obligatoire » et ne concerne pas le parcours Memo.
|
||||
|
||||
`0.4.4-pre.007` ajoute dans la même WebView un panneau SPL Token limité à `TransferChecked`, choisi comme parcours opérateur représentatif et non destructif. Le montant brut reste une chaîne décimale, le mint et les decimals sont explicites, et le backend affiche séparément préflight stateful, plan, simulation, confirmation, extraction, decode, nombre de matérialisations et second replay idempotent. La simulation reste le comportement par défaut ; l’envoi exige la confirmation commune et une autorité résolue par le wallet du profil.
|
||||
|
||||
La même fenêtre expose un journal read-only de `kb_sol_mat_events` borné à 500 lignes. Les filtres de signature, mint, compte, famille et opération sont contrôlés ; mint, compte et opération sont comparés exactement au payload typé après la requête PostgreSQL bornée. Le tableau montre slot, opération, famille, montant brut et signature, puis le payload JSON complet de la ligne sélectionnée. Il ne reconstruit ni snapshot final, ni solde, ni OHLC.
|
||||
|
||||
La validation Tauri du 15 juillet 2026 a démarré Vite et l'application, initialisé le schéma
|
||||
PostgreSQL, puis réussi le préflight et la simulation Token. Le bouton d'envoi a d'abord été refusé
|
||||
sans confirmation opérateur, puis refusé avec un diagnostic exact lorsque l'autorité Token fournie
|
||||
ne correspondait pas au wallet persistant du profil. Ce comportement est attendu : la WebView ne
|
||||
transporte aucune clé externe et l'orchestrateur ne peut signer qu'avec le wallet sélectionné. Un
|
||||
envoi depuis cette fenêtre exige donc des comptes dont l'autorité est ce wallet ; le parcours
|
||||
backend Devnet aligné a déjà validé l'envoi et la matérialisation complets.
|
||||
|
||||
`0.4.5-pre.005` ajoute dans la même fenêtre le panneau SPL Associated Token Account. Il sélectionne
|
||||
le Token Program classique ou Token-2022, dérive l'ATA readonly depuis wallet, programme et mint,
|
||||
puis propose `Create` ou `CreateIdempotent`. Le backend conserve simulation seule par défaut,
|
||||
affiche préflight, plafond rent/frais et postconditions, et exige la confirmation commune avant
|
||||
l'envoi. Après confirmation, il hydrate la transaction, extrait le core, décode les instructions
|
||||
ATA et CPI Token, matérialise leurs faits propriétaires puis vérifie un second replay idempotent.
|
||||
|
||||
Le journal ATA réutilise la requête PostgreSQL bornée du journal Token et limite la vue aux faits
|
||||
lifecycle du processor `spl_token_accounts`. Les filtres signature, mint, ATA et opération sont
|
||||
exacts ; aucune clé privée ni logique de dérivation n'est exposée dans la WebView. `RecoverNested`
|
||||
reste hors UI et a été validé séparément par le test opt-in Devnet contrôlé.
|
||||
|
||||
## Frontière de tracing
|
||||
|
||||
`kb_app_demo` utilise uniquement le target canonique `kb_app_demo`. Les anciens identifiants frontend sont validés puis conservés comme champ `frontend_target`; ils ne deviennent plus des targets Rust distincts.
|
||||
|
||||
L’application journalise les invocations Tauri, les actions utilisateur, l’état des fenêtres, la progression relayée et les résumés. Les détails de transport RPC, de parsing, de dispatch, de matérialisation et de persistance appartiennent respectivement à `kb_rpc`, `kb_pipeline`, aux processors et à `kb_store_pg`.
|
||||
|
||||
## Backfill depuis la dernière occurrence
|
||||
|
||||
Dans `demo_backfill`, la direction « Avant, plus anciennes » accepte désormais une signature d’ancrage vide pour les modes programme, token et pool. Le premier appel `getSignaturesForAddress` est alors effectué sans `before`, donc depuis les signatures les plus récentes. La direction « Après, plus récentes » conserve une ancre obligatoire.
|
||||
|
||||
## Validation de clôture `0.4.2`
|
||||
|
||||
`kb_app_demo` compte 88 tests. `cargo tauri dev -c kb_app_demo/tauri.conf.json` démarre Vite, initialise les tables PostgreSQL et permet d’exercer les sessions WebSocket. Aucun `npm run build` séparé n’est requis pour ce jalon : le serveur Vite lancé par Tauri constitue la validation frontend retenue.
|
||||
|
||||
## Token-2022 validation matrix
|
||||
|
||||
La fenêtre `DemoExecutionSpl` regroupe les exécutions Devnet SPL Memo, SPL Token classique et Associated Token Account. Elle réutilise les profils, la simulation obligatoire, la confirmation opérateur, l’hydratation canonique, l’extraction core, le décodage, la matérialisation et le second replay. La fenêtre Solana Core est désormais limitée au System Program. Token-2022 et ElGamal Registry seront ajoutés à cette fenêtre dédiée sans fusionner leurs contrats techniques.
|
||||
12
migration/khadhroony-bot2-reference/kb_app_demo/build.rs
Normal file
12
migration/khadhroony-bot2-reference/kb_app_demo/build.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// file: kb_app_demo/build.rs
|
||||
// version: 1
|
||||
|
||||
//! Build script for the `kb_app_demo` crate.
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for app",
|
||||
"windows": [
|
||||
"splash",
|
||||
"main",
|
||||
"demo_config",
|
||||
"demo_http",
|
||||
"demo_ws",
|
||||
"demo_backfill",
|
||||
"demo_core_extraction",
|
||||
"demo_execution_solana_core",
|
||||
"demo_decode_replay",
|
||||
"demo_sql_diag",
|
||||
"demo_sql_pg_raw",
|
||||
"demo_sql_pg_core",
|
||||
"demo_sql_replay_candidates",
|
||||
"demo_execution_spl"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"tracing:default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_backfill.html -->
|
||||
<!-- version: 2 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Khadhroony Bot2 - Backfill HTTP</title>
|
||||
<link rel="stylesheet" href="sass/main.scss" />
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<header class="app-header">
|
||||
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
|
||||
<div class="container my-0">
|
||||
<a class="navbar-brand d-flex align-items-center" href="main.html">
|
||||
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
|
||||
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Backfill HTTP</span>
|
||||
</a>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<span id="backfillStatusBadge" class="badge text-bg-secondary">Initialisation</span>
|
||||
<button id="cancelBackfillButton" class="btn btn-sm btn-outline-danger" type="button" disabled>Arrêter</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container py-4">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<h1 class="h3 card-title">Backfill transactionnel canonique</h1>
|
||||
<p class="text-body-secondary mb-0">Les signatures sont découvertes avec <code>getSignaturesForAddress</code>, hydratées avec <code>getTransaction</code>, puis stockées dans les tables canoniques et d'observation. Une seule campagne peut fonctionner à la fois.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion shadow-sm" id="backfillAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="sharedSettingsHeading">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#sharedSettingsCollapse" aria-expanded="true" aria-controls="sharedSettingsCollapse">Paramètres communs</button>
|
||||
</h2>
|
||||
<div id="sharedSettingsCollapse" class="accordion-collapse collapse show" aria-labelledby="sharedSettingsHeading" data-bs-parent="#backfillAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="backfillRoleSelect">Rôle HTTP</label>
|
||||
<select id="backfillRoleSelect" class="form-select"></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="backfillCommitmentSelect">Commitment</label>
|
||||
<select id="backfillCommitmentSelect" class="form-select">
|
||||
<option value="confirmed">confirmed</option>
|
||||
<option value="finalized">finalized</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label" for="backfillPageSizeInput">Taille de page</label>
|
||||
<input id="backfillPageSizeInput" class="form-control" type="number" min="1" max="1000" />
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label" for="backfillMaxPagesInput">Pages maximales</label>
|
||||
<input id="backfillMaxPagesInput" class="form-control" type="number" min="1" />
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label" for="backfillConcurrencyInput">Concurrence</label>
|
||||
<input id="backfillConcurrencyInput" class="form-control" type="number" min="1" />
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label" for="backfillRetriesInput">Retries</label>
|
||||
<input id="backfillRetriesInput" class="form-control" type="number" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="signatureBackfillHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#signatureBackfillCollapse" aria-expanded="false" aria-controls="signatureBackfillCollapse">Ensemble de signatures</button>
|
||||
</h2>
|
||||
<div id="signatureBackfillCollapse" class="accordion-collapse collapse" aria-labelledby="signatureBackfillHeading" data-bs-parent="#backfillAccordion">
|
||||
<div class="accordion-body">
|
||||
<label class="form-label" for="explicitSignaturesTextarea">Signatures, une par ligne</label>
|
||||
<textarea id="explicitSignaturesTextarea" class="form-control font-monospace mb-3" rows="10" placeholder="5abc... 4def..."></textarea>
|
||||
<button class="btn btn-primary backfill-start-button" type="button" data-backfill-mode="explicit_signatures">Lancer le backfill</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="programBackfillHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#programBackfillCollapse" aria-expanded="false" aria-controls="programBackfillCollapse">Programme Solana</button>
|
||||
</h2>
|
||||
<div id="programBackfillCollapse" class="accordion-collapse collapse" aria-labelledby="programBackfillHeading" data-bs-parent="#backfillAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12"><label class="form-label" for="programAddressInput">Program ID</label><input id="programAddressInput" class="form-control font-monospace" /></div>
|
||||
<div class="col-12"><label class="form-label" for="programAnchorInput">Signature d'ancrage</label><input id="programAnchorInput" class="form-control font-monospace" />
|
||||
<div class="form-text">Optionnelle pour « Avant, plus anciennes » : vide, la recherche commence aux transactions les plus récentes. Obligatoire pour « Après, plus récentes ».</div>
|
||||
</div>
|
||||
<div class="col-6"><label class="form-label" for="programDirectionSelect">Direction</label><select id="programDirectionSelect" class="form-select">
|
||||
<option value="before">Avant, plus anciennes</option>
|
||||
<option value="after">Après, plus récentes</option>
|
||||
</select></div>
|
||||
<div class="col-6"><label class="form-label" for="programLimitInput">Nombre de signatures</label><input id="programLimitInput" class="form-control" type="number" min="1" value="100" /></div>
|
||||
</div>
|
||||
<button class="btn btn-primary backfill-start-button" type="button" data-backfill-mode="program">Lancer le backfill programme</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="tokenBackfillHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#tokenBackfillCollapse" aria-expanded="false" aria-controls="tokenBackfillCollapse">Token</button>
|
||||
</h2>
|
||||
<div id="tokenBackfillCollapse" class="accordion-collapse collapse" aria-labelledby="tokenBackfillHeading" data-bs-parent="#backfillAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12"><label class="form-label" for="tokenAddressInput">Mint du token</label><input id="tokenAddressInput" class="form-control font-monospace" /></div>
|
||||
<div class="col-12"><label class="form-label" for="tokenAnchorInput">Signature d'ancrage</label><input id="tokenAnchorInput" class="form-control font-monospace" />
|
||||
<div class="form-text">Optionnelle pour « Avant, plus anciennes » : vide, la recherche commence aux transactions les plus récentes. Obligatoire pour « Après, plus récentes ».</div>
|
||||
</div>
|
||||
<div class="col-6"><label class="form-label" for="tokenDirectionSelect">Direction</label><select id="tokenDirectionSelect" class="form-select">
|
||||
<option value="before">Avant, plus anciennes</option>
|
||||
<option value="after">Après, plus récentes</option>
|
||||
</select></div>
|
||||
<div class="col-6"><label class="form-label" for="tokenLimitInput">Nombre de signatures</label><input id="tokenLimitInput" class="form-control" type="number" min="1" value="100" /></div>
|
||||
</div>
|
||||
<button class="btn btn-primary backfill-start-button" type="button" data-backfill-mode="token">Lancer le backfill token</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="poolBackfillHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#poolBackfillCollapse" aria-expanded="false" aria-controls="poolBackfillCollapse">Pool</button>
|
||||
</h2>
|
||||
<div id="poolBackfillCollapse" class="accordion-collapse collapse" aria-labelledby="poolBackfillHeading" data-bs-parent="#backfillAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12"><label class="form-label" for="poolAddressInput">Adresse du pool</label><input id="poolAddressInput" class="form-control font-monospace" /></div>
|
||||
<div class="col-12"><label class="form-label" for="poolAnchorInput">Signature d'ancrage</label><input id="poolAnchorInput" class="form-control font-monospace" />
|
||||
<div class="form-text">Optionnelle pour « Avant, plus anciennes » : vide, la recherche commence aux transactions les plus récentes. Obligatoire pour « Après, plus récentes ».</div>
|
||||
</div>
|
||||
<div class="col-6"><label class="form-label" for="poolDirectionSelect">Direction</label><select id="poolDirectionSelect" class="form-select">
|
||||
<option value="before">Avant, plus anciennes</option>
|
||||
<option value="after">Après, plus récentes</option>
|
||||
</select></div>
|
||||
<div class="col-6"><label class="form-label" for="poolLimitInput">Nombre de signatures</label><input id="poolLimitInput" class="form-control" type="number" min="1" value="100" /></div>
|
||||
</div>
|
||||
<button class="btn btn-primary backfill-start-button" type="button" data-backfill-mode="pool">Lancer le backfill pool</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="backfillLogHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#backfillLogCollapse" aria-expanded="false" aria-controls="backfillLogCollapse">Journal et résultat</button>
|
||||
</h2>
|
||||
<div id="backfillLogCollapse" class="accordion-collapse collapse" aria-labelledby="backfillLogHeading" data-bs-parent="#backfillAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="h6 mb-0">Journal</h3><button id="clearBackfillLogButton" class="btn btn-sm btn-outline-secondary" type="button">Effacer</button>
|
||||
</div>
|
||||
<textarea id="backfillLogOutput" class="form-control font-monospace mb-3" rows="15" readonly>Aucune campagne exécutée.</textarea>
|
||||
<h3 class="h6">Résumé JSON</h3>
|
||||
<textarea id="backfillSummaryOutput" class="form-control font-monospace" rows="16" readonly>Aucun résultat.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center">
|
||||
<div class="row flex-grow-1 align-items-center">
|
||||
<div class="col-12 text-center text-small my-1 my-md-0">© 2026 SASEDEV</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="module" src="ts/demo_backfill.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_config.html -->
|
||||
<!-- version: 2 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Khadhroony-Bot2 - Configuration</title>
|
||||
<link rel="stylesheet" href="sass/main.scss" />
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<header class="app-header">
|
||||
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
|
||||
<div class="container my-0">
|
||||
<a class="navbar-brand d-flex align-items-center" href="main.html">
|
||||
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
|
||||
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Bot2</span>
|
||||
</a>
|
||||
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<span class="badge text-bg-primary">Configuration</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container py-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h1 class="h3 card-title mb-3">Profil actif</h1>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Fichier</dt>
|
||||
<dd class="col-sm-9"><code id="configPath">Chargement...</code></dd>
|
||||
<dt class="col-sm-3">Profil</dt>
|
||||
<dd class="col-sm-9"><code id="activeProfileName">Chargement...</code></dd>
|
||||
<dt class="col-sm-3">Environnement</dt>
|
||||
<dd class="col-sm-9"><code id="activeEnvironment">Chargement...</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title mb-3">Configuration active</h2>
|
||||
<div id="activeProfileJson" class="app-config-box app-json-viewer mb-0">Chargement...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title mb-3">Configuration complète</h2>
|
||||
<div id="fullConfigJson" class="app-config-box app-json-viewer mb-0">Chargement...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title mb-3">Schéma JSON embarqué</h2>
|
||||
<div id="schemaJson" class="app-config-box app-json-viewer mb-0">Chargement...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center">
|
||||
<div class="row flex-grow-1 align-items-center">
|
||||
<div class="col-12 text-center text-small my-1 my-md-0">
|
||||
© 2026 SASEDEV
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="ts/demo_config.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,160 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_core_extraction.html -->
|
||||
<!-- version: 4 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Khadhroony Bot2 - Extraction canonical vers core</title>
|
||||
<link rel="stylesheet" href="sass/main.scss" />
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<header class="app-header">
|
||||
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
|
||||
<div class="container my-0">
|
||||
<a class="navbar-brand d-flex align-items-center" href="main.html">
|
||||
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
|
||||
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Extraction core</span>
|
||||
</a>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<span id="coreExtractionVersionBadge" class="badge text-bg-info">Version —</span>
|
||||
<span id="coreExtractionStatusBadge" class="badge text-bg-secondary">Initialisation</span>
|
||||
<button id="openRawDiagnosticsButton" class="btn btn-sm btn-outline-secondary" type="button">Diagnostic raw</button>
|
||||
<button id="openCoreDiagnosticsButton" class="btn btn-sm btn-outline-secondary" type="button">Diagnostic core</button>
|
||||
<button id="openReplayCandidatesButton" class="btn btn-sm btn-outline-primary" type="button">Candidats replay</button>
|
||||
<button id="cancelCoreExtractionButton" class="btn btn-sm btn-outline-danger" type="button" disabled>Arrêter</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container py-4">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<h1 class="h3 card-title">Extraction transactionnelle canonical vers core</h1>
|
||||
<p class="text-body-secondary mb-0">Chaque transaction canonique sélectionnée est validée, normalisée dans les tables core et enregistrée dans le ledger de traitement au sein d'une seule transaction PostgreSQL.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion shadow-sm" id="coreExtractionAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="coreExtractionSettingsHeading">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#coreExtractionSettingsCollapse" aria-expanded="true" aria-controls="coreExtractionSettingsCollapse">Paramètres communs</button>
|
||||
</h2>
|
||||
<div id="coreExtractionSettingsCollapse" class="accordion-collapse collapse show" aria-labelledby="coreExtractionSettingsHeading" data-bs-parent="#coreExtractionAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-6 col-lg-4">
|
||||
<label class="form-label" for="coreExtractionLimitInput">Transactions maximales</label>
|
||||
<input id="coreExtractionLimitInput" class="form-control" type="number" min="1" />
|
||||
</div>
|
||||
<div class="col-6 col-lg-4">
|
||||
<label class="form-label" for="coreExtractionConcurrencyInput">Concurrence</label>
|
||||
<input id="coreExtractionConcurrencyInput" class="form-control" type="number" min="1" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input id="coreExtractionForceReplayInput" class="form-check-input" type="checkbox" role="switch" />
|
||||
<label class="form-check-label" for="coreExtractionForceReplayInput">Forcer le remplacement des lignes déjà à jour</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="coreExtractionSignaturesHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#coreExtractionSignaturesCollapse" aria-expanded="false" aria-controls="coreExtractionSignaturesCollapse">Signatures explicites</button>
|
||||
</h2>
|
||||
<div id="coreExtractionSignaturesCollapse" class="accordion-collapse collapse" aria-labelledby="coreExtractionSignaturesHeading" data-bs-parent="#coreExtractionAccordion">
|
||||
<div class="accordion-body">
|
||||
<label class="form-label" for="coreExtractionSignaturesTextarea">Signatures canoniques déjà stockées, une par ligne</label>
|
||||
<textarea id="coreExtractionSignaturesTextarea" class="form-control font-monospace mb-3" rows="10" placeholder="5abc... 4def..."></textarea>
|
||||
<button class="btn btn-primary core-extraction-start-button" type="button" data-core-extraction-mode="signatures">Extraire les signatures</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="coreExtractionPendingHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#coreExtractionPendingCollapse" aria-expanded="false" aria-controls="coreExtractionPendingCollapse">Transactions en attente</button>
|
||||
</h2>
|
||||
<div id="coreExtractionPendingCollapse" class="accordion-collapse collapse" aria-labelledby="coreExtractionPendingHeading" data-bs-parent="#coreExtractionAccordion">
|
||||
<div class="accordion-body">
|
||||
<p class="text-body-secondary">Sélectionne les transactions canoniques dont l'état de traitement est <code>received</code>, dans l'ordre des slots.</p>
|
||||
<button class="btn btn-primary core-extraction-start-button" type="button" data-core-extraction-mode="pending">Extraire les transactions en attente</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="coreExtractionSlotsHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#coreExtractionSlotsCollapse" aria-expanded="false" aria-controls="coreExtractionSlotsCollapse">Plage de slots</button>
|
||||
</h2>
|
||||
<div id="coreExtractionSlotsCollapse" class="accordion-collapse collapse" aria-labelledby="coreExtractionSlotsHeading" data-bs-parent="#coreExtractionAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="coreExtractionMinSlotInput">Slot minimum inclusif</label>
|
||||
<input id="coreExtractionMinSlotInput" class="form-control" type="number" min="0" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="coreExtractionMaxSlotInput">Slot maximum inclusif</label>
|
||||
<input id="coreExtractionMaxSlotInput" class="form-control" type="number" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary core-extraction-start-button" type="button" data-core-extraction-mode="slot_range">Extraire la plage de slots</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="coreExtractionProgramHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#coreExtractionProgramCollapse" aria-expanded="false" aria-controls="coreExtractionProgramCollapse">Programme déjà indexé dans core</button>
|
||||
</h2>
|
||||
<div id="coreExtractionProgramCollapse" class="accordion-collapse collapse" aria-labelledby="coreExtractionProgramHeading" data-bs-parent="#coreExtractionAccordion">
|
||||
<div class="accordion-body">
|
||||
<p class="text-body-secondary">Sélectionne les transactions raw ayant déjà au moins une instruction core résolue pour ce programme. Ce mode sert aux replays après changement de version ou de hash.</p>
|
||||
<label class="form-label" for="coreExtractionProgramIdInput">Program ID</label>
|
||||
<input id="coreExtractionProgramIdInput" class="form-control font-monospace mb-3" />
|
||||
<button class="btn btn-primary core-extraction-start-button" type="button" data-core-extraction-mode="program_id">Extraire par programme</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="coreExtractionLogHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#coreExtractionLogCollapse" aria-expanded="false" aria-controls="coreExtractionLogCollapse">Journal et résultat</button>
|
||||
</h2>
|
||||
<div id="coreExtractionLogCollapse" class="accordion-collapse collapse" aria-labelledby="coreExtractionLogHeading" data-bs-parent="#coreExtractionAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="h6 mb-0">Journal</h3><button id="clearCoreExtractionLogButton" class="btn btn-sm btn-outline-secondary" type="button">Effacer</button>
|
||||
</div>
|
||||
<textarea id="coreExtractionLogOutput" class="form-control font-monospace mb-3" rows="15" readonly>Aucune campagne exécutée.</textarea>
|
||||
<h3 class="h6">Résumé JSON</h3>
|
||||
<textarea id="coreExtractionSummaryOutput" class="form-control font-monospace" rows="16" readonly>Aucun résultat.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center">
|
||||
<div class="row flex-grow-1 align-items-center">
|
||||
<div class="col-12 text-center text-small my-1 my-md-0">© 2026 SASEDEV</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="module" src="ts/demo_core_extraction.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,166 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_decode_replay.html -->
|
||||
<!-- version: 4 -->
|
||||
<!doctype html>
|
||||
<html lang="fr" data-bs-theme="light">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Décodage et matérialisation</title>
|
||||
<link rel="icon" href="imgs/favicon.png" />
|
||||
<link rel="stylesheet" href="sass/main.scss" />
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<header class="app-header">
|
||||
<nav class="navbar h-100 py-0 bg-light text-dark">
|
||||
<div class="container-fluid px-4">
|
||||
<span class="navbar-brand fw-bold">Décodage contextualisé et matérialisation</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span id="decodeReplayVersionBadge" class="badge text-bg-secondary">Version</span>
|
||||
<span id="decodeReplayStatusBadge" class="badge text-bg-success">Prêt</span>
|
||||
<button id="cancelDecodeReplayButton" class="btn btn-sm btn-outline-danger" type="button" disabled>Arrêter</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<h1 class="h4 mb-3">Sélection bornée</h1>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label" for="decodeReplayProgramIdInput">Program ID exact</label>
|
||||
<input id="decodeReplayProgramIdInput" class="form-control font-monospace" placeholder="Vide = tous les programmes des décodeurs sélectionnés" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-3">
|
||||
<label class="form-label" for="decodeReplayStateSelect">État instruction</label>
|
||||
<select id="decodeReplayStateSelect" class="form-select">
|
||||
<option value="incomplete_signatures">signatures incomplètes (pending + failed + replay_requested + unsupported)</option>
|
||||
<option value="actionable">pending + failed + replay_requested</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="replay_requested">replay_requested</option>
|
||||
<option value="decoded">decoded</option>
|
||||
<option value="ignored">ignored</option>
|
||||
<option value="materialized">materialized</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label" for="decodeReplayLimitInput">Limite</label>
|
||||
<input id="decodeReplayLimitInput" class="form-control" type="number" min="1" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label" for="decodeReplaySignaturesTextarea">Signatures explicites, une par ligne</label>
|
||||
<textarea id="decodeReplaySignaturesTextarea" class="form-control font-monospace" rows="7" placeholder="Copier depuis demo_sql_replay_candidates"></textarea>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label" for="decodeReplayPathsTextarea">Instruction paths exacts, un par ligne</label>
|
||||
<textarea id="decodeReplayPathsTextarea" class="form-control font-monospace" rows="7" placeholder="0 1/0"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 mb-3">Dispatch et replay</h2>
|
||||
<div id="decodeReplayDecoderList" class="mb-3"></div>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label" for="decodeReplayConcurrencyInput">Concurrence</label>
|
||||
<input id="decodeReplayConcurrencyInput" class="form-control" type="number" min="1" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-9">
|
||||
<div class="form-check form-switch">
|
||||
<input id="decodeReplayAllCompatibleInput" class="form-check-input" type="checkbox" role="switch" />
|
||||
<label class="form-check-label" for="decodeReplayAllCompatibleInput">Exécuter tous les décodeurs compatibles</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input id="decodeReplayForceInput" class="form-check-input" type="checkbox" role="switch" disabled />
|
||||
<label class="form-check-label" for="decodeReplayForceInput">Force replay du processor/version/input</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input id="decodeReplayAllSignaturesInput" class="form-check-input" type="checkbox" role="switch" />
|
||||
<label class="form-check-label" for="decodeReplayAllSignaturesInput">Toutes les signatures correspondant aux filtres, dans la limite indiquée</label>
|
||||
</div>
|
||||
<div id="decodeReplayForceScopeHelp" class="form-text mb-2">Sans signatures explicites, cocher Toutes les signatures pour autoriser un force replay borné. Le filtre d’état est alors ignoré, mais les programmes, paths et la limite restent appliqués.</div>
|
||||
<div class="form-check form-switch">
|
||||
<input id="decodeReplayMaterializeInput" class="form-check-input" type="checkbox" role="switch" disabled />
|
||||
<label class="form-check-label" for="decodeReplayMaterializeInput">Appeler les matérialiseurs disponibles après persistance du décodage</label>
|
||||
</div>
|
||||
<div id="decodeReplayMaterializeHelp" class="form-text">Chargement de la disponibilité des matérialiseurs…</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button id="startDecodeReplayButton" class="btn btn-primary" type="button">Lancer le replay de décodage</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button id="openReplayCandidatesFromDecodeButton" class="btn btn-sm btn-outline-primary" type="button">Candidats replay</button>
|
||||
<button id="openCoreDiagnosticsFromDecodeButton" class="btn btn-sm btn-outline-secondary" type="button">Diagnostic core</button>
|
||||
<button id="loadDecodeDiagnosticsButton" class="btn btn-sm btn-outline-secondary" type="button">Decode / ledger / couverture</button>
|
||||
</div>
|
||||
<h2 class="h5">Journal</h2>
|
||||
<textarea id="decodeReplayLogOutput" class="form-control font-monospace mb-3" rows="14" readonly>Aucune campagne exécutée.</textarea>
|
||||
<h2 class="h5">Résumé</h2>
|
||||
<textarea id="decodeReplaySummaryOutput" class="form-control font-monospace mb-3" rows="15" readonly>Aucun résultat.</textarea>
|
||||
<h2 class="h5">Diagnostics</h2>
|
||||
<textarea id="decodeReplayDiagnosticsOutput" class="form-control font-monospace" rows="18" readonly>Diagnostics non chargés.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap align-items-end gap-3 mb-3">
|
||||
<div class="flex-grow-1">
|
||||
<label class="form-label" for="annotationSignatureInput">Journal des annotations — signature contient</label>
|
||||
<input id="annotationSignatureInput" class="form-control font-monospace" placeholder="Filtre facultatif" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="annotationLimitInput">Limite</label>
|
||||
<input id="annotationLimitInput" class="form-control" type="number" min="1" max="500" value="100" />
|
||||
</div>
|
||||
<button id="loadAnnotationsButton" class="btn btn-outline-primary" type="button">Charger les annotations</button>
|
||||
</div>
|
||||
<p id="annotationJournalStatus" class="text-body-secondary mb-3">Annotations non chargées.</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Slot</th>
|
||||
<th>Signature</th>
|
||||
<th>Path</th>
|
||||
<th>Génération</th>
|
||||
<th>Texte</th>
|
||||
<th>Octets</th>
|
||||
<th>Signataires vérifiés</th>
|
||||
<th>SHA-256 / provenance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="annotationJournalBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center justify-content-center">© 2026 SASEDEV</div>
|
||||
</footer>
|
||||
<script type="module" src="ts/demo_decode_replay.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_execution_solana_core.html -->
|
||||
<!-- version: 6 -->
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Exécution Solana Core Devnet</title>
|
||||
<link href="sass/main.scss" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="app-body">
|
||||
<header class="app-header bg-dark text-light">
|
||||
<nav class="container-fluid h-100 d-flex align-items-center justify-content-between px-4">
|
||||
<div>
|
||||
<h1 class="h4 mb-0">Exécution Solana Core Devnet</h1><small class="text-light-emphasis">System Program uniquement — simulation-first et replay post-exécution</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2"><span class="badge text-bg-success" id="executionStatusBadge">Prêt</span><button class="btn btn-sm btn-outline-warning" disabled="" id="cancelExecutionButton" type="button">Annuler</button></div>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar="">
|
||||
<div class="container-fluid py-4 px-4">
|
||||
<div class="alert alert-warning">Cette fenêtre est strictement réservée aux opérations Solana Core. Les opérations SPL sont disponibles dans « Exécution SPL Devnet ».</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card shadow-sm border-0"><div class="card-body"><div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3"><h2 class="h5 mb-0">System Program</h2><span class="badge text-bg-primary">solana_native_classifier</span></div>
|
||||
|
||||
<div class="mb-3"><label class="form-label" for="executionProfileSelect">Profil Devnet</label><select class="form-select" id="executionProfileSelect"></select>
|
||||
<div class="form-text" id="executionProfileHelp"></div>
|
||||
</div>
|
||||
<div class="mb-3"><label class="form-label" for="executionRecipientInput">Destinataire</label>
|
||||
<div class="input-group"><input autocomplete="off" class="form-control font-monospace" id="executionRecipientInput" type="text"/><button class="btn btn-outline-secondary" id="generateRecipientButton" type="button">Générer</button></div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-6"><label class="form-label" for="executionLamportsInput">Lamports</label><input class="form-control" id="executionLamportsInput" min="1" step="1" type="number"/></div>
|
||||
<div class="col-6"><label class="form-label" for="executionAirdropInput">Airdrop maximal</label><input class="form-control" id="executionAirdropInput" min="0" step="1" type="number" value="0"/></div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="form-check form-switch mb-2"><input class="form-check-input" id="executionOperatorConfirmedInput" type="checkbox"/><label class="form-check-label" for="executionOperatorConfirmedInput">Confirmation opérateur explicite</label></div>
|
||||
<div class="form-check form-switch mb-2"><input class="form-check-input" id="executionForceReplayInput" type="checkbox"/><label class="form-check-label" for="executionForceReplayInput">Forcer le replay post-exécution</label></div>
|
||||
<div class="form-check form-switch mb-3"><input class="form-check-input" id="executionMaterializeInput" type="checkbox"/><label class="form-check-label" for="executionMaterializeInput">Matérialiser après décodage</label></div>
|
||||
<div class="d-grid gap-2 d-md-flex"><button class="btn btn-outline-primary flex-fill" id="simulateExecutionButton" type="button">Simuler uniquement</button><button class="btn btn-danger flex-fill" id="submitExecutionButton" type="button">Signer et envoyer</button></div>
|
||||
</div></div>
|
||||
<div class="card shadow-sm border-0 mt-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h2 class="h5 mb-0">Journal</h2><button class="btn btn-sm btn-outline-secondary" id="clearExecutionLogButton" type="button">Effacer</button>
|
||||
</div><textarea class="form-control font-monospace" id="executionLogOutput" readonly="" rows="18"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 mb-3">Résumé</h2><textarea class="form-control font-monospace" id="executionSummaryOutput" readonly="" rows="14">Aucun résultat.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0 mt-4">
|
||||
<div class="card-body">
|
||||
<h2 class="h5">Plan exact</h2><textarea class="form-control font-monospace" id="executionPlanOutput" readonly="" rows="12">Aucun plan.</textarea>
|
||||
<h2 class="h5 mt-3">Simulation exacte</h2><textarea class="form-control font-monospace" id="executionSimulationOutput" readonly="" rows="12">Aucune simulation.</textarea>
|
||||
<h2 class="h5 mt-3">Confirmation et replay</h2><textarea class="form-control font-monospace" id="executionDiagnosticsOutput" readonly="" rows="12">Aucun diagnostic.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script defer="" src="ts/demo_execution_solana_core.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,427 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_execution_spl.html -->
|
||||
<!-- version: 8 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>Exécution SPL Devnet</title>
|
||||
<link href="sass/main.scss" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body class="app-body">
|
||||
<header class="app-header bg-dark text-light">
|
||||
<nav class="container-fluid h-100 d-flex align-items-center justify-content-between px-4">
|
||||
<div>
|
||||
<h1 class="h4 mb-0">Exécution SPL Devnet</h1>
|
||||
<small class="text-light-emphasis">Memo, SPL Token classique, Token-2022 et ATA — simulation-first et replay post-exécution</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge text-bg-success" id="executionStatusBadge">Prêt</span>
|
||||
<button class="btn btn-sm btn-outline-warning" disabled="" id="cancelExecutionButton" type="button">Annuler</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar="">
|
||||
<div class="container-fluid py-4 px-4">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
Cette fenêtre est limitée à Devnet. La clé privée du wallet source reste dans le backend Rust et n’est jamais transmise à la WebView.
|
||||
</div>
|
||||
<div class="row g-4" id="splExecutionWorkspace">
|
||||
<div class="col-12 col-xl-5" id="splExecutionControls">
|
||||
<div class="accordion shadow-sm" id="walletPreparationAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="walletPreparationHeading"><button aria-controls="walletPreparationCollapse" aria-expanded="true" class="accordion-button" data-bs-target="#walletPreparationCollapse" data-bs-toggle="collapse" type="button"><span class="d-flex flex-wrap align-items-center justify-content-between gap-2 w-100 me-2"><span>Préparation commune du wallet Devnet</span><span class="badge text-bg-info">Pré-requis</span></span></button></h2>
|
||||
<div aria-labelledby="walletPreparationHeading" class="accordion-collapse collapse show" id="walletPreparationCollapse">
|
||||
<div class="accordion-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="executionProfileSelect">Profil Devnet</label>
|
||||
<select class="form-select" id="executionProfileSelect"></select>
|
||||
<div class="form-text" id="executionProfileHelp"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="executionRecipientInput">Destinataire</label>
|
||||
<div class="input-group">
|
||||
<input autocomplete="off" class="form-control font-monospace" id="executionRecipientInput" type="text" />
|
||||
<button class="btn btn-outline-secondary" id="generateRecipientButton" type="button">Générer</button>
|
||||
</div>
|
||||
<div class="form-text">Une adresse générée ici est jetable : sa clé privée n’est ni conservée ni exposée.</div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="executionLamportsInput">Lamports transférés</label>
|
||||
<input class="form-control" id="executionLamportsInput" min="1" step="1" type="number" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="executionAirdropInput">Airdrop maximal</label>
|
||||
<input class="form-control" id="executionAirdropInput" min="0" step="1" type="number" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" id="executionOperatorConfirmedInput" type="checkbox" />
|
||||
<label class="form-check-label" for="executionOperatorConfirmedInput">Confirmation opérateur explicite</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" id="executionForceReplayInput" type="checkbox" />
|
||||
<label class="form-check-label" for="executionForceReplayInput">Forcer le replay post-exécution</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" id="executionMaterializeInput" type="checkbox" />
|
||||
<label class="form-check-label" for="executionMaterializeInput">Matérialiser après décodage</label>
|
||||
</div>
|
||||
<div class="d-grid gap-2 d-md-flex">
|
||||
<button class="btn btn-outline-primary flex-fill" id="simulateExecutionButton" type="button">Simuler uniquement</button>
|
||||
<button class="btn btn-danger flex-fill" id="submitExecutionButton" type="button">Signer et envoyer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between mt-4 mb-2">
|
||||
<h2 class="h5 mb-0">Exécutions par décodeur</h2><small class="text-body-secondary">Ouvrir une seule famille à la fois</small>
|
||||
</div>
|
||||
<div class="accordion shadow-sm mt-4" id="decoderExecutionAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="splMemoDecoderHeading"><button aria-controls="splMemoDecoderCollapse" aria-expanded="false" class="accordion-button collapsed" data-bs-target="#splMemoDecoderCollapse" data-bs-toggle="collapse" type="button"><span class="d-flex flex-wrap align-items-center justify-content-between gap-2 w-100 me-2"><span>Décodeur spl_memo — Memo v4</span><span class="badge text-bg-primary">Exécutable</span></span></button></h2>
|
||||
<div aria-labelledby="splMemoDecoderHeading" class="accordion-collapse collapse" data-bs-parent="#decoderExecutionAccordion" id="splMemoDecoderCollapse">
|
||||
<div class="accordion-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="memoMessageInput">Annotation UTF-8 exacte</label>
|
||||
<textarea class="form-control" id="memoMessageInput" maxlength="566" rows="5">khadhroony-bot2 Memo v4 validation</textarea>
|
||||
<div class="form-text">566 octets UTF-8 maximum. La matérialisation `transaction_annotation` et le second replay d’idempotence sont obligatoires après envoi.</div>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input checked="" class="form-check-input" id="memoWalletSignerInput" type="checkbox" />
|
||||
<label class="form-check-label" for="memoWalletSignerInput">Inclure le wallet comme signer Memo readonly</label>
|
||||
</div>
|
||||
<div class="alert alert-secondary py-2 small" role="note">
|
||||
Ce parcours réutilise le profil, la confirmation opérateur et l’option de force replay du panneau System ci-dessus. Aucune clé privée ne traverse la WebView.
|
||||
</div>
|
||||
<div class="d-grid gap-2 d-md-flex">
|
||||
<button class="btn btn-outline-info flex-fill" id="simulateMemoButton" type="button">Simuler Memo</button>
|
||||
<button class="btn btn-danger flex-fill" id="submitMemoButton" type="button">Signer et envoyer Memo</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="splTokenDecoderHeading"><button aria-controls="splTokenDecoderCollapse" aria-expanded="false" class="accordion-button collapsed" data-bs-target="#splTokenDecoderCollapse" data-bs-toggle="collapse" type="button"><span class="d-flex flex-wrap align-items-center justify-content-between gap-2 w-100 me-2"><span>Décodeur spl_token — TransferChecked</span><span class="badge text-bg-primary">Exécutable</span></span></button></h2>
|
||||
<div aria-labelledby="splTokenDecoderHeading" class="accordion-collapse collapse" data-bs-parent="#decoderExecutionAccordion" id="splTokenDecoderCollapse">
|
||||
<div class="accordion-body">
|
||||
<div class="alert alert-secondary py-2 small" role="note">
|
||||
Le mint et les decimals sont explicites. Le préflight vérifie les deux comptes, le solde et l’autorité avant toute simulation. L’envoi réutilise le profil et la confirmation opérateur communs.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="tokenSourceInput">Compte source</label>
|
||||
<input autocomplete="off" class="form-control font-monospace" id="tokenSourceInput" type="text" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="tokenMintInput">Mint</label>
|
||||
<input autocomplete="off" class="form-control font-monospace" id="tokenMintInput" type="text" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="tokenDestinationInput">Compte destination</label>
|
||||
<input autocomplete="off" class="form-control font-monospace" id="tokenDestinationInput" type="text" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="tokenAuthorityInput">Autorité simple</label>
|
||||
<input autocomplete="off" class="form-control font-monospace" id="tokenAuthorityInput" type="text" />
|
||||
<div class="form-text">Doit correspondre au wallet persistant du profil pour un envoi.</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-md-7">
|
||||
<label class="form-label" for="tokenAmountRawInput">Montant brut exact</label>
|
||||
<input class="form-control font-monospace" id="tokenAmountRawInput" inputmode="numeric" type="text" value="1" />
|
||||
</div>
|
||||
<div class="col-12 col-md-5">
|
||||
<label class="form-label" for="tokenDecimalsInput">Decimals</label>
|
||||
<input class="form-control" id="tokenDecimalsInput" max="255" min="0" step="1" type="number" value="9" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-grid gap-2 d-md-flex">
|
||||
<button class="btn btn-outline-primary flex-fill" id="simulateTokenButton" type="button">Préflight et simuler</button>
|
||||
<button class="btn btn-danger flex-fill" id="submitTokenButton" type="button">Signer et envoyer Token</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="splAtaDecoderHeading"><button aria-controls="splAtaDecoderCollapse" aria-expanded="false" class="accordion-button collapsed" data-bs-target="#splAtaDecoderCollapse" data-bs-toggle="collapse" type="button"><span class="d-flex flex-wrap align-items-center justify-content-between gap-2 w-100 me-2"><span>Décodeur spl_associated_token_account — Create / CreateIdempotent</span><span class="badge text-bg-primary">Exécutable</span></span></button></h2>
|
||||
<div aria-labelledby="splAtaDecoderHeading" class="accordion-collapse collapse" data-bs-parent="#decoderExecutionAccordion" id="splAtaDecoderCollapse">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="ataTokenProgramSelect">Token Program cible</label>
|
||||
<select class="form-select" id="ataTokenProgramSelect">
|
||||
<option value="classic">SPL Token classique</option>
|
||||
<option value="token_2022">Token-2022</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="ataModeSelect">Mode</label>
|
||||
<select class="form-select" id="ataModeSelect">
|
||||
<option value="create_idempotent">CreateIdempotent</option>
|
||||
<option value="create">Create</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ataMintInput">Mint</label>
|
||||
<input autocomplete="off" class="form-control font-monospace" id="ataMintInput" type="text" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ataPayerInput">Payer readonly</label>
|
||||
<input class="form-control font-monospace" id="ataPayerInput" readonly="" type="text" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ataWalletOwnerInput">Wallet owner readonly</label>
|
||||
<input class="form-control font-monospace" id="ataWalletOwnerInput" readonly="" type="text" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ataDerivedInput">ATA dérivé readonly</label>
|
||||
<input class="form-control font-monospace" id="ataDerivedInput" readonly="" type="text" />
|
||||
<div class="form-text">Actualisé depuis le backend avec les seeds wallet, Token Program et mint.</div>
|
||||
</div>
|
||||
<div class="d-grid gap-2 d-md-flex">
|
||||
<button class="btn btn-outline-secondary flex-fill" id="deriveAtaButton" type="button">Dériver</button>
|
||||
<button class="btn btn-outline-primary flex-fill" id="simulateAtaButton" type="button">Préflight et simuler</button>
|
||||
<button class="btn btn-danger flex-fill" id="submitAtaButton" type="button">Confirmer et envoyer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="splToken2022DecoderHeading"><button aria-controls="splToken2022DecoderCollapse" aria-expanded="false" class="accordion-button collapsed" data-bs-target="#splToken2022DecoderCollapse" data-bs-toggle="collapse" type="button"><span class="d-flex flex-wrap align-items-center justify-content-between gap-2 w-100 me-2"><span>Exécution spl_token_2022 — scénarios publics</span><span class="badge text-bg-success">Exécutable</span></span></button></h2>
|
||||
<div aria-labelledby="splToken2022DecoderHeading" class="accordion-collapse collapse" data-bs-parent="#decoderExecutionAccordion" id="splToken2022DecoderCollapse">
|
||||
<div class="accordion-body">
|
||||
<div class="alert alert-info">Chaque opération est simulée sur le message exact avant toute signature. Les scénarios sont indépendants et réutilisent la fixture chargée dans les deux terminaux.</div>
|
||||
<div class="d-grid mb-3"><button class="btn btn-outline-secondary" id="loadToken2022FixtureButton" type="button">Charger la fixture Token-2022</button></div>
|
||||
<div class="form-text mb-3" id="token2022FixtureHelp">Aucune fixture chargée.</div>
|
||||
<div class="mb-3"><label class="form-label" for="token2022ScenarioSelect">Scénario</label><select class="form-select" id="token2022ScenarioSelect">
|
||||
<option value="token_2022_mint_to_checked">MintToChecked</option>
|
||||
<option value="token_2022_transfer_checked">TransferChecked</option>
|
||||
<option value="token_2022_approve_checked">ApproveChecked</option>
|
||||
<option value="token_2022_revoke">Revoke</option>
|
||||
<option value="token_2022_burn_checked">BurnChecked</option>
|
||||
<option value="token_2022_freeze_account">FreezeAccount</option>
|
||||
<option value="token_2022_thaw_account">ThawAccount</option>
|
||||
<option value="token_2022_close_destination">CloseAccount</option>
|
||||
</select></div>
|
||||
<div class="mb-3"><label class="form-label" for="token2022SourceInput">Compte source / compte cible</label><input class="form-control font-monospace" id="token2022SourceInput" type="text" /></div>
|
||||
<div class="mb-3"><label class="form-label" for="token2022MintInput">Mint Token-2022</label><input class="form-control font-monospace" id="token2022MintInput" type="text" /></div>
|
||||
<div class="mb-3"><label class="form-label" for="token2022DestinationInput">Compte destination / destination lamports</label><input class="form-control font-monospace" id="token2022DestinationInput" type="text" /></div>
|
||||
<div class="mb-3"><label class="form-label" for="token2022DelegateInput">Delegate</label><input class="form-control font-monospace" id="token2022DelegateInput" type="text" /></div>
|
||||
<div class="mb-3"><label class="form-label" for="token2022AuthorityInput">Autorité</label><input class="form-control font-monospace" id="token2022AuthorityInput" type="text" /></div>
|
||||
<div class="mb-3"><label class="form-label" for="token2022FreezeAuthorityInput">Freeze authority</label><input class="form-control font-monospace" id="token2022FreezeAuthorityInput" type="text" /></div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8"><label class="form-label" for="token2022AmountInput">Montant brut</label><input class="form-control" id="token2022AmountInput" type="text" value="1" /></div>
|
||||
<div class="col-4"><label class="form-label" for="token2022DecimalsInput">Décimales</label><input class="form-control" id="token2022DecimalsInput" max="255" min="0" type="number" value="9" /></div>
|
||||
</div>
|
||||
<div class="d-grid gap-2 d-md-flex">
|
||||
<button class="btn btn-outline-primary flex-fill" id="simulateToken2022Button" type="button">Préflight et simuler</button>
|
||||
<button class="btn btn-danger flex-fill" id="submitToken2022Button" type="button">Confirmer et envoyer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="splElgamalRegistryDecoderHeading"><button aria-controls="splElgamalRegistryDecoderCollapse" aria-expanded="false" class="accordion-button collapsed" data-bs-target="#splElgamalRegistryDecoderCollapse" data-bs-toggle="collapse" type="button"><span class="d-flex flex-wrap align-items-center justify-content-between gap-2 w-100 me-2"><span>Décodeur spl_elgamal_registry — ElGamal Registry</span><span class="badge text-bg-primary">Plan exact</span></span></button></h2>
|
||||
<div aria-labelledby="splElgamalRegistryDecoderHeading" class="accordion-collapse collapse" data-bs-parent="#decoderExecutionAccordion" id="splElgamalRegistryDecoderCollapse">
|
||||
<div class="accordion-body">
|
||||
<div class="alert alert-info">Première démo : dériver le Registry PDA et construire le plan exact avec un Proof Context State existant.</div>
|
||||
<div class="mb-3"><label class="form-label" for="elgamalFeePayerInput">Fee payer</label><input class="form-control font-monospace" id="elgamalFeePayerInput" type="text" /></div>
|
||||
<div class="mb-3"><label class="form-label" for="elgamalOwnerInput">Owner du registre</label><input class="form-control font-monospace" id="elgamalOwnerInput" type="text" /></div>
|
||||
<div class="mb-3"><label class="form-label" for="elgamalOperationSelect">Opération</label><select class="form-select" id="elgamalOperationSelect">
|
||||
<option value="create_registry">CreateRegistry</option>
|
||||
<option value="update_registry">UpdateRegistry</option>
|
||||
</select></div>
|
||||
<div class="mb-3"><label class="form-label" for="elgamalProofContextInput">Proof Context State account</label><input class="form-control font-monospace" id="elgamalProofContextInput" type="text" /></div><button class="btn btn-outline-primary w-100" id="buildElgamalRegistryPlanButton" type="button">Construire le plan Registry</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-secondary mt-4 mb-0">
|
||||
<strong>Scénarios avec preuve.</strong> CreateRegistry, UpdateRegistry et ConfigureAccountWithRegistry restent dans le panneau ElGamal/Confidential et exigent un Proof Context State valide. Ils ne sont pas présentés comme des scénarios publics exécutables.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-7" id="splExecutionResults">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<h2 class="h5 mb-0">Résultats détaillés</h2><small class="text-body-secondary">Plan, simulation, confirmation et journaux métier</small>
|
||||
</div>
|
||||
<div class="accordion mt-4" id="executionDetailsAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" data-bs-target="#executionPlanCollapse" data-bs-toggle="collapse" type="button">Plan exact</button>
|
||||
</h2>
|
||||
<div class="accordion-collapse collapse" data-bs-parent="#executionDetailsAccordion" id="executionPlanCollapse">
|
||||
<div class="accordion-body">
|
||||
<textarea class="form-control font-monospace" id="executionPlanOutput" readonly="" rows="16">Aucun plan.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" data-bs-target="#executionSimulationCollapse" data-bs-toggle="collapse" type="button">Simulation exacte</button>
|
||||
</h2>
|
||||
<div class="accordion-collapse collapse" data-bs-parent="#executionDetailsAccordion" id="executionSimulationCollapse">
|
||||
<div class="accordion-body">
|
||||
<textarea class="form-control font-monospace" id="executionSimulationOutput" readonly="" rows="16">Aucune simulation.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" data-bs-target="#executionDiagnosticsCollapse" data-bs-toggle="collapse" type="button">Confirmation et replay</button>
|
||||
</h2>
|
||||
<div class="accordion-collapse collapse" data-bs-parent="#executionDetailsAccordion" id="executionDiagnosticsCollapse">
|
||||
<div class="accordion-body">
|
||||
<textarea class="form-control font-monospace" id="executionDiagnosticsOutput" readonly="" rows="16">Aucun diagnostic.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion shadow-sm mt-4" id="splBusinessJournalsAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="splTokenJournalHeading"><button aria-controls="splTokenJournalCollapse" aria-expanded="false" class="accordion-button collapsed" data-bs-target="#splTokenJournalCollapse" data-bs-toggle="collapse" type="button">Journal métier SPL Token</button></h2>
|
||||
<div aria-labelledby="splTokenJournalHeading" class="accordion-collapse collapse" data-bs-parent="#splBusinessJournalsAccordion" id="splTokenJournalCollapse">
|
||||
<div class="accordion-body p-0" id="splTokenJournalHost">
|
||||
<div class="card border-0 rounded-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-0">Journal métier SPL Token</h2>
|
||||
<div class="form-text">Événements commités uniquement, sans solde final reconstruit ni OHLC.</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary" id="refreshTokenJournalButton" type="button">Actualiser</button>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label" for="tokenJournalSignatureInput">Signature contient</label>
|
||||
<input class="form-control font-monospace" id="tokenJournalSignatureInput" type="text" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label" for="tokenJournalMintInput">Mint exact</label>
|
||||
<input class="form-control font-monospace" id="tokenJournalMintInput" type="text" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label" for="tokenJournalAccountInput">Compte exact</label>
|
||||
<input class="form-control font-monospace" id="tokenJournalAccountInput" type="text" />
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label" for="tokenJournalFamilySelect">Famille</label>
|
||||
<select class="form-select" id="tokenJournalFamilySelect">
|
||||
<option value="">Toutes</option>
|
||||
<option value="token_account">token_account</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="risk">risk</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label" for="tokenJournalLimitInput">Limite</label>
|
||||
<input class="form-control" id="tokenJournalLimitInput" max="500" min="1" step="1" type="number" value="100" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="tokenJournalOperationInput">Opération exacte</label>
|
||||
<input class="form-control font-monospace" id="tokenJournalOperationInput" placeholder="transfer_checked" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Slot</th>
|
||||
<th>Opération</th>
|
||||
<th>Famille</th>
|
||||
<th>Montant brut</th>
|
||||
<th>Signature</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tokenJournalRows">
|
||||
<tr>
|
||||
<td class="text-body-secondary" colspan="5">Aucune lecture.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<label class="form-label" for="tokenJournalDetailOutput">Détail JSON de la ligne sélectionnée</label>
|
||||
<textarea class="form-control font-monospace" id="tokenJournalDetailOutput" readonly="" rows="14">Aucun événement sélectionné.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="ataLifecycleJournalHeading"><button aria-controls="ataLifecycleJournalCollapse" aria-expanded="false" class="accordion-button collapsed" data-bs-target="#ataLifecycleJournalCollapse" data-bs-toggle="collapse" type="button">Journal lifecycle ATA</button></h2>
|
||||
<div aria-labelledby="ataLifecycleJournalHeading" class="accordion-collapse collapse" data-bs-parent="#splBusinessJournalsAccordion" id="ataLifecycleJournalCollapse">
|
||||
<div class="accordion-body p-0" id="ataLifecycleJournalHost">
|
||||
<div class="card border-0 rounded-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-0">Journal lifecycle ATA</h2>
|
||||
<div class="form-text">Création, réutilisation idempotente et récupération nested, sans duplication des CPI Token.</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary" id="refreshAtaJournalButton" type="button">Actualiser</button>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label" for="ataJournalSignatureInput">Signature contient</label>
|
||||
<input class="form-control font-monospace" id="ataJournalSignatureInput" type="text" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label" for="ataJournalMintInput">Mint exact</label>
|
||||
<input class="form-control font-monospace" id="ataJournalMintInput" type="text" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label" for="ataJournalAddressInput">ATA exact</label>
|
||||
<input class="form-control font-monospace" id="ataJournalAddressInput" type="text" />
|
||||
</div>
|
||||
<div class="col-8 col-lg-4">
|
||||
<label class="form-label" for="ataJournalOperationInput">Opération</label>
|
||||
<input class="form-control font-monospace" id="ataJournalOperationInput" type="text" />
|
||||
</div>
|
||||
<div class="col-4 col-lg-2">
|
||||
<label class="form-label" for="ataJournalLimitInput">Limite</label>
|
||||
<input class="form-control" id="ataJournalLimitInput" max="500" min="1" type="number" value="100" />
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="form-control font-monospace" id="ataJournalOutput" readonly="" rows="14">Aucune lecture.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0 mt-4">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 mb-3">Résumé</h2>
|
||||
<textarea class="form-control font-monospace" id="executionSummaryOutput" readonly="" rows="14">Aucun résultat.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0 mt-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h2 class="h5 mb-0">Journal</h2>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="clearExecutionLogButton" type="button">Effacer</button>
|
||||
</div>
|
||||
<textarea class="form-control font-monospace" id="executionLogOutput" readonly="" rows="18"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center">
|
||||
<div class="row flex-grow-1 align-items-center">
|
||||
<div class="col-12 text-center text-small my-1 my-md-0">© 2026 SASEDEV</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script defer="" src="ts/demo_execution_spl.ts" type="module"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_http.html -->
|
||||
<!-- version: 3 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Khadhroony Bot2 - HTTP JSON-RPC</title>
|
||||
<link rel="stylesheet" href="sass/main.scss" />
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<header class="app-header">
|
||||
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
|
||||
<div class="container my-0">
|
||||
<a class="navbar-brand d-flex align-items-center" href="#">
|
||||
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
|
||||
<span class="ps-2 fs-4 fw-bold text-primary font-logo">HTTP JSON-RPC</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container py-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h1 class="h4 card-title">Requête HTTP Solana standard</h1>
|
||||
<p class="text-body-secondary">Cette démo route une requête JSON-RPC HTTP par rôle d'endpoint.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="httpRoleSelect">Rôle</label>
|
||||
<select id="httpRoleSelect" class="form-select"></select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="httpMethodSelect">Méthode</label>
|
||||
<select id="httpMethodSelect" class="form-select"></select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="httpFirstArgInput">Premier argument</label>
|
||||
<input id="httpFirstArgInput" class="form-control" placeholder="Signature, pubkey ou transaction encodée" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="httpConfigInput">Config JSON</label>
|
||||
<textarea id="httpConfigInput" class="form-control font-monospace" rows="4" placeholder='{"commitment":"confirmed"}'></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="httpParamsInput">Params JSON complet</label>
|
||||
<textarea id="httpParamsInput" class="form-control font-monospace" rows="4" placeholder='["pubkey", {"encoding":"jsonParsed"}]'></textarea>
|
||||
<div class="form-text">Si renseigné, ce tableau JSON remplace le premier argument et la config JSON.</div>
|
||||
</div>
|
||||
<button id="executeHttpButton" class="btn btn-primary">Exécuter</button>
|
||||
<button id="refreshHttpPoolButton" class="btn btn-outline-secondary ms-2">Rafraîchir endpoints</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h2 class="h5 card-title mb-0">Endpoints HTTP</h2>
|
||||
<button id="copyHttpPoolButton" class="btn btn-sm btn-outline-secondary">Copier</button>
|
||||
</div>
|
||||
<textarea id="httpPoolOutput" class="form-control font-monospace" rows="10" readonly>Chargement...</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h2 class="h5 card-title mb-0">Résultat</h2>
|
||||
<button id="copyHttpResultButton" class="btn btn-sm btn-outline-secondary">Copier</button>
|
||||
</div>
|
||||
<textarea id="httpResultOutput" class="form-control font-monospace" rows="18" readonly>Aucune requête exécutée.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center">
|
||||
<div class="row flex-grow-1 align-items-center">
|
||||
<div class="col-12 text-center text-small my-1 my-md-0">© 2026 SASEDEV</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="ts/demo_http.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,115 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_sql_diag.html -->
|
||||
<!-- version: 2 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Khadhroony-Bot2 - SQL diagnostics</title>
|
||||
<link rel="stylesheet" href="sass/main.scss" />
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<header class="app-header">
|
||||
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
|
||||
<div class="container my-0">
|
||||
<a class="navbar-brand d-flex align-items-center" href="main.html">
|
||||
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
|
||||
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Bot2</span>
|
||||
</a>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<span class="badge text-bg-primary">SQL diagnostics</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container py-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 card-title mb-0">Diagnostic PostgreSQL</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="refreshSqlDiagButton" class="btn btn-primary" type="button">Rafraîchir</button>
|
||||
<button id="copySqlDiagButton" class="btn btn-outline-secondary" type="button">Copier JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Statut</dt>
|
||||
<dd class="col-sm-9"><code id="sqlDiagStatus">Chargement...</code></dd>
|
||||
<dt class="col-sm-3">Fichier config</dt>
|
||||
<dd class="col-sm-9"><code id="sqlDiagConfigPath">—</code></dd>
|
||||
<dt class="col-sm-3">Profil</dt>
|
||||
<dd class="col-sm-9"><code id="sqlDiagProfile">—</code></dd>
|
||||
<dt class="col-sm-3">Backend</dt>
|
||||
<dd class="col-sm-9"><code id="sqlDiagBackend">—</code></dd>
|
||||
<dt class="col-sm-3">DSN</dt>
|
||||
<dd class="col-sm-9"><code id="sqlDiagDsn">—</code></dd>
|
||||
<dt class="col-sm-3">Schema courant</dt>
|
||||
<dd class="col-sm-9"><code id="sqlDiagSchema">—</code></dd>
|
||||
<dt class="col-sm-3">Healthcheck</dt>
|
||||
<dd class="col-sm-9"><code id="sqlDiagHealth">—</code></dd>
|
||||
<dt class="col-sm-3">Migrations</dt>
|
||||
<dd class="col-sm-9"><code id="sqlDiagMigration">—</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title mb-3">Tables raw/core connues</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th>Domaine</th>
|
||||
<th>Rôle</th>
|
||||
<th>Existe</th>
|
||||
<th>Lignes</th>
|
||||
<th>Slot min</th>
|
||||
<th>Slot max</th>
|
||||
<th>Dernière insertion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sqlDiagTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title mb-3">Payload JSON</h2>
|
||||
<div id="sqlDiagJson" class="app-config-box app-json-viewer mb-0">Chargement...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center">
|
||||
<div class="row flex-grow-1 align-items-center">
|
||||
<div class="col-12 text-center text-small my-1 my-md-0">
|
||||
© 2026 SASEDEV
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="ts/demo_sql_diag.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_sql_pg_core.html -->
|
||||
<!-- version: 3 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Khadhroony-Bot2 - PostgreSQL core store</title>
|
||||
<link rel="stylesheet" href="sass/main.scss" />
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<header class="app-header">
|
||||
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
|
||||
<div class="container my-0">
|
||||
<a class="navbar-brand d-flex align-items-center" href="main.html">
|
||||
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
|
||||
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Bot2</span>
|
||||
</a>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<span class="badge text-bg-primary">PostgreSQL core</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container py-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 card-title mb-0">Core store Solana</h1>
|
||||
<button id="refreshSqlCoreButton" class="btn btn-primary" type="button">Rafraîchir</button>
|
||||
</div>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Statut</dt>
|
||||
<dd class="col-sm-9"><code id="sqlCoreStatus">Chargement...</code></dd>
|
||||
<dt class="col-sm-3">Profil</dt>
|
||||
<dd class="col-sm-9"><code id="sqlCoreProfile">—</code></dd>
|
||||
<dt class="col-sm-3">DSN</dt>
|
||||
<dd class="col-sm-9"><code id="sqlCoreDsn">—</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title mb-3">Tables core</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th>Domaine</th>
|
||||
<th>Rôle</th>
|
||||
<th>Existe</th>
|
||||
<th>Lignes</th>
|
||||
<th>Slot min</th>
|
||||
<th>Slot max</th>
|
||||
<th>Dernière insertion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sqlCoreTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title mb-3">Payload JSON</h2>
|
||||
<div id="sqlCoreJson" class="app-config-box app-json-viewer mb-0">Chargement...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center">
|
||||
<div class="row flex-grow-1 align-items-center">
|
||||
<div class="col-12 text-center text-small my-1 my-md-0">
|
||||
© 2026 SASEDEV
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="ts/demo_sql_pg_core.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_sql_pg_raw.html -->
|
||||
<!-- version: 4 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Khadhroony-Bot2 - PostgreSQL canonical acquisition</title>
|
||||
<link rel="stylesheet" href="sass/main.scss" />
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<header class="app-header">
|
||||
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
|
||||
<div class="container my-0">
|
||||
<a class="navbar-brand d-flex align-items-center" href="main.html">
|
||||
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
|
||||
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Bot2</span>
|
||||
</a>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<span class="badge text-bg-primary">PostgreSQL acquisition</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container py-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 card-title mb-0">Acquisition transactionnelle Solana</h1>
|
||||
<button id="refreshSqlRawButton" class="btn btn-primary" type="button">Rafraîchir</button>
|
||||
</div>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Statut</dt>
|
||||
<dd class="col-sm-9"><code id="sqlRawStatus">Chargement...</code></dd>
|
||||
<dt class="col-sm-3">Profil</dt>
|
||||
<dd class="col-sm-9"><code id="sqlRawProfile">—</code></dd>
|
||||
<dt class="col-sm-3">DSN</dt>
|
||||
<dd class="col-sm-9"><code id="sqlRawDsn">—</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title mb-3">Transactions canoniques et observations</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th>Domaine</th>
|
||||
<th>Rôle</th>
|
||||
<th>Existe</th>
|
||||
<th>Lignes</th>
|
||||
<th>Slot min</th>
|
||||
<th>Slot max</th>
|
||||
<th>Dernière insertion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sqlRawTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title mb-3">Payload JSON</h2>
|
||||
<div id="sqlRawJson" class="app-config-box app-json-viewer mb-0">Chargement...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center">
|
||||
<div class="row flex-grow-1 align-items-center">
|
||||
<div class="col-12 text-center text-small my-1 my-md-0">
|
||||
© 2026 SASEDEV
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="ts/demo_sql_pg_raw.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,443 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_sql_replay_candidates.html -->
|
||||
<!-- version: 6 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Khadhroony Bot2 - Candidats de replay SQL</title>
|
||||
<link rel="stylesheet" href="sass/main.scss" />
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<header class="app-header">
|
||||
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
|
||||
<div class="container-fluid px-4 my-0">
|
||||
<a class="navbar-brand d-flex align-items-center" href="main.html">
|
||||
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
|
||||
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Candidats de replay SQL</span>
|
||||
</a>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<span id="replayCandidateProfileBadge" class="badge text-bg-info">Profil —</span>
|
||||
<span id="replayCandidateStatusBadge" class="badge text-bg-secondary">Initialisation</span>
|
||||
<button id="openCoreExtractionButton" class="btn btn-sm btn-outline-primary" type="button">Ouvrir extraction core</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<h1 class="h3 card-title">Exploration read-only des candidats</h1>
|
||||
<p class="text-body-secondary mb-2">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 <code>./data/exports_csv/</code>. LibreOffice Calc peut toujours afficher son dialogue d’import, car le format CSV ne décrit pas formellement son encodage ni son séparateur.</p>
|
||||
<div class="small text-body-secondary">
|
||||
<span class="fw-semibold">DSN :</span>
|
||||
<code id="replayCandidateDsn">—</code>
|
||||
<span class="ms-3 fw-semibold">Limite serveur maximale :</span>
|
||||
<code id="replayCandidateMaximumLimit">—</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" id="replayCandidateTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="transactionsTabButton" data-bs-toggle="tab" data-bs-target="#transactionsTabPane" type="button" role="tab" aria-controls="transactionsTabPane" aria-selected="true">Transactions et signatures</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="programsTabButton" data-bs-toggle="tab" data-bs-target="#programsTabPane" type="button" role="tab" aria-controls="programsTabPane" aria-selected="false">Programmes</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="mintsTabButton" data-bs-toggle="tab" data-bs-target="#mintsTabPane" type="button" role="tab" aria-controls="mintsTabPane" aria-selected="false">Mints</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="ownersTabButton" data-bs-toggle="tab" data-bs-target="#ownersTabPane" type="button" role="tab" aria-controls="ownersTabPane" aria-selected="false">Owners</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="accountsTabButton" data-bs-toggle="tab" data-bs-target="#accountsTabPane" type="button" role="tab" aria-controls="accountsTabPane" aria-selected="false">Comptes</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="replayCandidateTabContent">
|
||||
<section class="tab-pane fade show active" id="transactionsTabPane" role="tabpanel" aria-labelledby="transactionsTabButton" tabindex="0">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Filtres PostgreSQL bornés</h2>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-xl-4">
|
||||
<label for="transactionSignatureContainsInput" class="form-label">Signature contient</label>
|
||||
<input id="transactionSignatureContainsInput" class="form-control font-monospace" type="text" autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-xl-2">
|
||||
<label for="transactionMinSlotInput" class="form-label">Slot minimum</label>
|
||||
<input id="transactionMinSlotInput" class="form-control" type="number" min="0" step="1" />
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-xl-2">
|
||||
<label for="transactionMaxSlotInput" class="form-label">Slot maximum</label>
|
||||
<input id="transactionMaxSlotInput" class="form-control" type="number" min="0" step="1" />
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-xl-2">
|
||||
<label for="transactionRawStateSelect" class="form-label">État raw</label>
|
||||
<select id="transactionRawStateSelect" class="form-select">
|
||||
<option value="">Tous</option>
|
||||
<option value="received">received</option>
|
||||
<option value="core_extracted">core_extracted</option>
|
||||
<option value="decoded">decoded</option>
|
||||
<option value="materialized">materialized</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-xl-2">
|
||||
<label for="transactionLedgerStatusSelect" class="form-label">Ledger core</label>
|
||||
<select id="transactionLedgerStatusSelect" class="form-select">
|
||||
<option value="">Tous</option>
|
||||
<option value="not_started">not_started</option>
|
||||
<option value="running">running</option>
|
||||
<option value="succeeded">succeeded</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-xl-5">
|
||||
<label for="transactionProgramIdInput" class="form-label">Program ID exact</label>
|
||||
<input id="transactionProgramIdInput" class="form-control font-monospace" type="text" list="knownProgramDatalist" autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-xl-2">
|
||||
<label for="transactionProgramScopeSelect" class="form-label">Portée programme</label>
|
||||
<select id="transactionProgramScopeSelect" class="form-select">
|
||||
<option value="any">outer + inner + logs</option>
|
||||
<option value="outer">outer uniquement</option>
|
||||
<option value="inner">inner uniquement</option>
|
||||
<option value="logs">logs liés uniquement</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-xl-2">
|
||||
<label for="transactionEntityKindSelect" class="form-label">Type d’entité</label>
|
||||
<select id="transactionEntityKindSelect" class="form-select">
|
||||
<option value="">Aucune</option>
|
||||
<option value="mint">mint</option>
|
||||
<option value="owner">owner</option>
|
||||
<option value="account_key">account_key</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-xl-3">
|
||||
<label for="transactionEntityValueInput" class="form-label">Entité exacte</label>
|
||||
<input id="transactionEntityValueInput" class="form-control font-monospace" type="text" autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-xl-1">
|
||||
<label for="transactionLimitInput" class="form-label">Limite</label>
|
||||
<input id="transactionLimitInput" class="form-control" type="number" min="1" max="5000" step="1" value="500" />
|
||||
</div>
|
||||
<div class="col-6 col-md-3 col-xl-2">
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input id="transactionNewestFirstInput" class="form-check-input" type="checkbox" role="switch" checked />
|
||||
<label class="form-check-label" for="transactionNewestFirstInput">Plus récents d’abord</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-3">
|
||||
<div class="d-grid d-sm-flex gap-2">
|
||||
<button id="resetTransactionsButton" class="btn btn-outline-secondary" type="button">Réinitialiser</button>
|
||||
<button id="loadTransactionsButton" class="btn btn-primary flex-grow-1" type="button">Charger les transactions</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="h5 card-title mb-1">Transactions candidates</h2>
|
||||
<div id="transactionResultSummary" class="small text-body-secondary">Aucune requête exécutée.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="btn-group flex-wrap" role="group" aria-label="Actions signatures">
|
||||
<button id="copySignaturesButton" class="btn btn-sm btn-outline-primary" type="button">Copier signatures multiligne</button>
|
||||
<button id="exportTransactionsCsvButton" class="btn btn-sm btn-outline-success" type="button">Exporter CSV</button>
|
||||
</div>
|
||||
<div class="small text-body-secondary mt-1">Les identifiants longs sont tronqués à l’écran. Survolez-les pour afficher leur valeur complète ou utilisez le bouton de copie individuel.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table id="transactionCandidateTable" class="table table-striped table-hover table-sm align-middle w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="replay-select-column"><span class="visually-hidden">Sélection</span></th>
|
||||
<th>Signature</th>
|
||||
<th>Slot</th>
|
||||
<th>Raw</th>
|
||||
<th title="État de présence core ; échec on-chain signifie que la transaction Solana a échoué, pas que l’extraction core a échoué.">Core</th>
|
||||
<th>Ledger</th>
|
||||
<th>Version</th>
|
||||
<th>Essais</th>
|
||||
<th data-bs-toggle="tooltip" title="Nombre total d’instructions top-level.">Outer inst.</th>
|
||||
<th data-bs-toggle="tooltip" title="Nombre total d’instructions internes.">Inner inst.</th>
|
||||
<th data-bs-toggle="tooltip" title="Nombre de program IDs distincts dans les instructions top-level.">Outer pr.</th>
|
||||
<th data-bs-toggle="tooltip" title="Nombre de program IDs distincts dans les instructions internes.">Inner pr.</th>
|
||||
<th>Mise à jour</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-pane fade" id="programsTabPane" role="tabpanel" aria-labelledby="programsTabButton" tabindex="0">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-lg-5">
|
||||
<label for="knownProgramSelect" class="form-label">Programme connu (kb_program_ids)</label>
|
||||
<select id="knownProgramSelect" class="form-select">
|
||||
<option value="">Tous les programmes connus</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<label for="programContainsInput" class="form-label">Program ID contient</label>
|
||||
<input id="programContainsInput" class="form-control font-monospace" type="text" list="knownProgramDatalist" autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-6 col-lg-1">
|
||||
<label for="programLimitInput" class="form-label">Limite</label>
|
||||
<input id="programLimitInput" class="form-control" type="number" min="1" max="5000" step="1" value="500" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-2">
|
||||
<div class="d-grid gap-2">
|
||||
<button id="resetProgramsButton" class="btn btn-outline-secondary" type="button">Réinitialiser</button>
|
||||
<button id="loadProgramsButton" class="btn btn-primary" type="button">Charger les programmes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="h5 card-title mb-1">Programmes indexés</h2>
|
||||
<div id="programResultSummary" class="small text-body-secondary">Aucune requête exécutée.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="btn-group flex-wrap" role="group" aria-label="Actions programmes">
|
||||
<button id="useSelectedProgramButton" class="btn btn-sm btn-outline-secondary" type="button">Utiliser comme filtre transactions</button>
|
||||
<button id="copyProgramsButton" class="btn btn-sm btn-outline-primary" type="button">Copier IDs multiligne</button>
|
||||
<button id="exportProgramsCsvButton" class="btn btn-sm btn-outline-success" type="button">Exporter CSV</button>
|
||||
</div>
|
||||
<div class="small text-body-secondary mt-1">Cochez un programme. Sans sélection, l’action accepte une seule ligne restant après le filtre local.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table id="programCandidateTable" class="table table-striped table-hover table-sm align-middle w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="replay-select-column"><span class="visually-hidden">Sélection</span></th>
|
||||
<th>Code connu</th>
|
||||
<th>Program ID</th>
|
||||
<th>Transactions</th>
|
||||
<th>Outer</th>
|
||||
<th>Inner</th>
|
||||
<th>Logs liés</th>
|
||||
<th>Slot min</th>
|
||||
<th>Slot max</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-pane fade" id="mintsTabPane" role="tabpanel" aria-labelledby="mintsTabButton" tabindex="0">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-lg-7">
|
||||
<label for="mintContainsInput" class="form-label">Mint contient</label>
|
||||
<input id="mintContainsInput" class="form-control font-monospace" type="text" autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label for="mintLimitInput" class="form-label">Limite</label>
|
||||
<input id="mintLimitInput" class="form-control" type="number" min="1" max="5000" step="1" value="500" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-3">
|
||||
<div class="d-grid d-sm-flex gap-2">
|
||||
<button id="resetMintsButton" class="btn btn-outline-secondary" type="button">Réinitialiser</button>
|
||||
<button id="loadMintsButton" class="btn btn-primary flex-grow-1" type="button">Charger les mints</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="h5 card-title mb-1">Mints</h2>
|
||||
<div id="mintResultSummary" class="small text-body-secondary">Aucune requête exécutée.</div>
|
||||
<div class="small text-body-secondary">Source : <code>kb_sol_core_balance_changes.mint</code>.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="btn-group flex-wrap" role="group" aria-label="Actions mints">
|
||||
<button id="useSelectedMintsButton" class="btn btn-sm btn-outline-secondary" type="button">Utiliser comme filtre transactions</button>
|
||||
<button id="copyMintsButton" class="btn btn-sm btn-outline-primary" type="button">Copier valeurs multiligne</button>
|
||||
<button id="exportMintsCsvButton" class="btn btn-sm btn-outline-success" type="button">Exporter CSV</button>
|
||||
</div>
|
||||
<div class="small text-body-secondary mt-1">Cochez une ligne. Sans sélection, l’action accepte une seule ligne restant après le filtre local.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table id="mintCandidateTable" class="table table-striped table-hover table-sm align-middle w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="replay-select-column"><span class="visually-hidden">Sélection</span></th>
|
||||
<th>Mint</th>
|
||||
<th data-bs-toggle="tooltip" title="Nombre de signatures distinctes dans lesquelles la valeur apparaît.">Transactions</th>
|
||||
<th data-bs-toggle="tooltip" title="Nombre total de lignes core portant cette valeur.">Occurrences</th>
|
||||
<th data-bs-toggle="tooltip" title="Occurrences divisées par le nombre de transactions distinctes.">Occ./tx</th>
|
||||
<th data-bs-toggle="tooltip" title="Premier slot observé dans le corpus core.">Slot min</th>
|
||||
<th data-bs-toggle="tooltip" title="Dernier slot observé dans le corpus core.">Slot max</th>
|
||||
<th data-bs-toggle="tooltip" title="Différence entre le slot maximal et le slot minimal.">Étendue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-pane fade" id="ownersTabPane" role="tabpanel" aria-labelledby="ownersTabButton" tabindex="0">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-lg-7">
|
||||
<label for="ownerContainsInput" class="form-label">Owner contient</label>
|
||||
<input id="ownerContainsInput" class="form-control font-monospace" type="text" autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label for="ownerLimitInput" class="form-label">Limite</label>
|
||||
<input id="ownerLimitInput" class="form-control" type="number" min="1" max="5000" step="1" value="500" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-3">
|
||||
<div class="d-grid d-sm-flex gap-2">
|
||||
<button id="resetOwnersButton" class="btn btn-outline-secondary" type="button">Réinitialiser</button>
|
||||
<button id="loadOwnersButton" class="btn btn-primary flex-grow-1" type="button">Charger les owners</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="h5 card-title mb-1">Owners</h2>
|
||||
<div id="ownerResultSummary" class="small text-body-secondary">Aucune requête exécutée.</div>
|
||||
<div class="small text-body-secondary">Source : <code>kb_sol_core_balance_changes.owner</code>.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="btn-group flex-wrap" role="group" aria-label="Actions owners">
|
||||
<button id="useSelectedOwnersButton" class="btn btn-sm btn-outline-secondary" type="button">Utiliser comme filtre transactions</button>
|
||||
<button id="copyOwnersButton" class="btn btn-sm btn-outline-primary" type="button">Copier valeurs multiligne</button>
|
||||
<button id="exportOwnersCsvButton" class="btn btn-sm btn-outline-success" type="button">Exporter CSV</button>
|
||||
</div>
|
||||
<div class="small text-body-secondary mt-1">Cochez une ligne. Sans sélection, l’action accepte une seule ligne restant après le filtre local.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table id="ownerCandidateTable" class="table table-striped table-hover table-sm align-middle w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="replay-select-column"><span class="visually-hidden">Sélection</span></th>
|
||||
<th>Owner</th>
|
||||
<th data-bs-toggle="tooltip" title="Nombre de signatures distinctes dans lesquelles la valeur apparaît.">Transactions</th>
|
||||
<th data-bs-toggle="tooltip" title="Nombre total de lignes core portant cette valeur.">Occurrences</th>
|
||||
<th data-bs-toggle="tooltip" title="Occurrences divisées par le nombre de transactions distinctes.">Occ./tx</th>
|
||||
<th data-bs-toggle="tooltip" title="Premier slot observé dans le corpus core.">Slot min</th>
|
||||
<th data-bs-toggle="tooltip" title="Dernier slot observé dans le corpus core.">Slot max</th>
|
||||
<th data-bs-toggle="tooltip" title="Différence entre le slot maximal et le slot minimal.">Étendue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-pane fade" id="accountsTabPane" role="tabpanel" aria-labelledby="accountsTabButton" tabindex="0">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-lg-7">
|
||||
<label for="accountContainsInput" class="form-label">Account key contient</label>
|
||||
<input id="accountContainsInput" class="form-control font-monospace" type="text" autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label for="accountLimitInput" class="form-label">Limite</label>
|
||||
<input id="accountLimitInput" class="form-control" type="number" min="1" max="5000" step="1" value="500" />
|
||||
</div>
|
||||
<div class="col-12 col-lg-3">
|
||||
<div class="d-grid d-sm-flex gap-2">
|
||||
<button id="resetAccountsButton" class="btn btn-outline-secondary" type="button">Réinitialiser</button>
|
||||
<button id="loadAccountsButton" class="btn btn-primary flex-grow-1" type="button">Charger les comptes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="h5 card-title mb-1">Comptes</h2>
|
||||
<div id="accountResultSummary" class="small text-body-secondary">Aucune requête exécutée.</div>
|
||||
<div class="small text-body-secondary">Source : <code>kb_sol_core_account_keys.account_key</code>.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="btn-group flex-wrap" role="group" aria-label="Actions comptes">
|
||||
<button id="useSelectedAccountsButton" class="btn btn-sm btn-outline-secondary" type="button">Utiliser comme filtre transactions</button>
|
||||
<button id="copyAccountsButton" class="btn btn-sm btn-outline-primary" type="button">Copier valeurs multiligne</button>
|
||||
<button id="exportAccountsCsvButton" class="btn btn-sm btn-outline-success" type="button">Exporter CSV</button>
|
||||
</div>
|
||||
<div class="small text-body-secondary mt-1">Cochez une ligne. Sans sélection, l’action accepte une seule ligne restant après le filtre local.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table id="accountCandidateTable" class="table table-striped table-hover table-sm align-middle w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="replay-select-column"><span class="visually-hidden">Sélection</span></th>
|
||||
<th>Account key</th>
|
||||
<th data-bs-toggle="tooltip" title="Nombre de signatures distinctes dans lesquelles la valeur apparaît.">Transactions</th>
|
||||
<th data-bs-toggle="tooltip" title="Nombre total de lignes core portant cette valeur.">Occurrences</th>
|
||||
<th data-bs-toggle="tooltip" title="Occurrences divisées par le nombre de transactions distinctes.">Occ./tx</th>
|
||||
<th data-bs-toggle="tooltip" title="Premier slot observé dans le corpus core.">Slot min</th>
|
||||
<th data-bs-toggle="tooltip" title="Dernier slot observé dans le corpus core.">Slot max</th>
|
||||
<th data-bs-toggle="tooltip" title="Différence entre le slot maximal et le slot minimal.">Étendue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center">
|
||||
<div class="row flex-grow-1 align-items-center">
|
||||
<div class="col-12 text-center text-small my-1 my-md-0">© 2026 SASEDEV</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<datalist id="knownProgramDatalist"></datalist>
|
||||
<script type="module" src="ts/demo_sql_replay_candidates.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,113 @@
|
||||
<!-- file: kb_app_demo/frontend/demo_ws.html -->
|
||||
<!-- version: 5 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Khadhroony Bot2 - WebSocket standard</title>
|
||||
<link rel="stylesheet" href="sass/main.scss" />
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<header class="app-header">
|
||||
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
|
||||
<div class="container my-0">
|
||||
<a class="navbar-brand d-flex align-items-center" href="#">
|
||||
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
|
||||
<span class="ps-2 fs-4 fw-bold text-primary font-logo">WebSocket standard</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container py-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h1 class="h4 card-title">Souscription WebSocket Solana standard</h1>
|
||||
<p class="text-body-secondary">Cette démo garde une connexion WebSocket ouverte dans AppState. Le bouton Souscrire réutilise la même connexion tant que l’endpoint reste identique ; la déconnexion est explicite ou réalisée à la fermeture globale de l’application.</p>
|
||||
<p class="small text-warning mb-3">Les notifications très fréquentes sont limitées côté Rust puis bornées côté interface pour éviter de bloquer la WebView.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="wsRoleSelect">Rôle</label>
|
||||
<select id="wsRoleSelect" class="form-select"></select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="wsMethodSelect">Méthode</label>
|
||||
<select id="wsMethodSelect" class="form-select"></select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="wsTargetInput">Target</label>
|
||||
<input id="wsTargetInput" class="form-control" placeholder="Pubkey, program id ou signature" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="wsFilterInput">Filter JSON</label>
|
||||
<textarea id="wsFilterInput" class="form-control font-monospace" rows="4" placeholder='{"mentions":["..."]}'></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="wsConfigInput">Config JSON</label>
|
||||
<textarea id="wsConfigInput" class="form-control font-monospace" rows="4" placeholder='{"commitment":"confirmed"}'></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="wsSubscriptionSelect">Subscriptions actives</label>
|
||||
<select id="wsSubscriptionSelect" class="form-select"></select>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button id="connectWsButton" class="btn btn-primary">Souscrire</button>
|
||||
<button id="unsubscribeWsButton" class="btn btn-outline-warning">Unsubscribe sélectionnée</button>
|
||||
<button id="disconnectWsButton" class="btn btn-outline-danger">Déconnecter socket</button>
|
||||
<button id="refreshWsPoolButton" class="btn btn-outline-secondary">Rafraîchir endpoints</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h2 class="h5 card-title mb-0">Statut</h2>
|
||||
<span id="wsStatusBadge" class="badge text-bg-secondary">Déconnecté</span>
|
||||
</div>
|
||||
<textarea id="wsStatusOutput" class="form-control font-monospace" rows="6" readonly>Déconnecté.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h2 class="h5 card-title mb-0">Endpoints WebSocket</h2>
|
||||
<button id="copyWsPoolButton" class="btn btn-sm btn-outline-secondary">Copier</button>
|
||||
</div>
|
||||
<textarea id="wsPoolOutput" class="form-control font-monospace" rows="8" readonly>Chargement...</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h2 class="h5 card-title mb-0">Messages</h2>
|
||||
<button id="copyWsResultButton" class="btn btn-sm btn-outline-secondary">Copier</button>
|
||||
</div>
|
||||
<textarea id="wsResultOutput" class="form-control font-monospace" rows="16" readonly>Aucune connexion active.</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center">
|
||||
<div class="row flex-grow-1 align-items-center">
|
||||
<div class="col-12 text-center text-small my-1 my-md-0">© 2026 SASEDEV</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="ts/demo_ws.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 611 KiB |
@@ -0,0 +1,131 @@
|
||||
<!-- file: kb_app_demo/frontend/main.html -->
|
||||
<!-- version: 13 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Khadhroony-Bot2</title>
|
||||
<link rel="stylesheet" href="sass/main.scss" />
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<header class="app-header">
|
||||
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
|
||||
<div class="container my-0">
|
||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
|
||||
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Bot2</span>
|
||||
</a>
|
||||
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Démos
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a id="openDemoConfigLink" class="dropdown-item" href="#">
|
||||
Configuration et logging
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="openDemoHttpLink" class="dropdown-item" href="#">
|
||||
HTTP JSON-RPC standard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="openDemoWsLink" class="dropdown-item" href="#">
|
||||
WebSocket standard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="openDemoBackfillLink" class="dropdown-item" href="#">
|
||||
Backfill HTTP
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="openDemoCoreExtractionLink" class="dropdown-item" href="#">
|
||||
Extraction canonical vers core
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="openDemoDecodeReplayLink" class="dropdown-item" href="#">
|
||||
Décodage et matérialisation
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="openDemoExecutionSolanaCoreLink" class="dropdown-item" href="#">
|
||||
Exécution Solana Core Devnet
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="openDemoExecutionSplLink" class="dropdown-item" href="#">
|
||||
Exécution SPL Devnet
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="dropdown-divider" />
|
||||
</li>
|
||||
<li>
|
||||
<a id="openDemoSqlDiagLink" class="dropdown-item" href="#">
|
||||
SQL diagnostics
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="openDemoSqlPgRawLink" class="dropdown-item" href="#">
|
||||
PostgreSQL raw store
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="openDemoSqlPgCoreLink" class="dropdown-item" href="#">
|
||||
PostgreSQL core store
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="openDemoSqlReplayCandidatesLink" class="dropdown-item" href="#">
|
||||
SQL replay candidates
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container py-4">
|
||||
<div class="row g-4 justify-content-center">
|
||||
<div class="col-12 col-xl-10">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body text-start">
|
||||
<h1 class="h3 card-title mb-3">README du projet</h1>
|
||||
<p class="text-body-secondary mb-3">
|
||||
Cette fenêtre d'accueil charge automatiquement le fichier README depuis le backend Rust.
|
||||
</p>
|
||||
<article id="readmeContent" class="app-readme-box app-markdown-body mb-0">Chargement du README...</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer bg-dark text-light">
|
||||
<div class="container h-100 d-flex align-items-center">
|
||||
<div class="row flex-grow-1 align-items-center">
|
||||
<div class="col-12 text-center text-small my-1 my-md-0">
|
||||
© 2026 SASEDEV
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="ts/main.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,385 @@
|
||||
// file: kb_app_demo/frontend/sass/_app.scss
|
||||
// version: 5
|
||||
|
||||
$app-header-height: 48px;
|
||||
$app-footer-height: 48px;
|
||||
$sidenav-base-width: 260px;
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#ibackground {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
z-index: 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#vbackground {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
z-index: 2;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
video#vbackground:not([playsinline]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: $app-header-height;
|
||||
z-index: 1030;
|
||||
|
||||
& nav {
|
||||
border-top: none !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-bottom: 3px $primary solid !important;
|
||||
border-top-left-radius: 0 !important;
|
||||
border-top-right-radius: 0 !important;
|
||||
border-bottom-left-radius: 15px 150px;
|
||||
border-bottom-right-radius: 75px 25px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: $app-footer-height;
|
||||
z-index: 1020;
|
||||
border-style: solid;
|
||||
border-width: 2px 0 0;
|
||||
border-top-left-radius: 255px 25px;
|
||||
border-top-right-radius: 25px 225px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
position: relative;
|
||||
height: calc(100vh - $app-header-height - $app-footer-height); // plein viewport
|
||||
margin-top: $app-header-height;
|
||||
margin-bottom: $app-footer-height;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden; // pas de scroll ici
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.app-main-error {
|
||||
position: relative;
|
||||
height: 100vh; // plein viewport
|
||||
padding-top: $app-header-height;
|
||||
padding-bottom: $app-footer-height;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden; // pas de scroll ici
|
||||
background: linear-gradient(135deg, #9945ff 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
z-index: 501;
|
||||
}
|
||||
|
||||
.app-main-error-content {
|
||||
width: 100%;
|
||||
|
||||
& h1 {
|
||||
font-size: 4rem;
|
||||
margin: 0;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
& p {
|
||||
font-size: 1.5rem;
|
||||
margin: 1rem 0 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
height: 42px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#app-dashbord {
|
||||
display: flex;
|
||||
height: 100%; //height: calc(100vh - var(--app-header-height) - var(--app-footer-height));
|
||||
}
|
||||
|
||||
#app-dashbord-sidenav {
|
||||
flex-basis: $sidenav-base-width;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease-in-out;
|
||||
|
||||
&[dir="ltr"] {
|
||||
transform: translateX(-$sidenav-base-width);
|
||||
}
|
||||
|
||||
&[dir="rtl"] {
|
||||
transform: translateX($sidenav-base-width);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#app-dashbord-main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
min-height: 100%;
|
||||
|
||||
&[dir="ltr"] {
|
||||
margin-left: -$sidenav-base-width;
|
||||
}
|
||||
|
||||
&[dir="rtl"] {
|
||||
margin-right: -$sidenav-base-width;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// quand body a la classe .sidenav-toggled
|
||||
.sidenav-toggled {
|
||||
#app-dashbord-sidenav {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
#app-dashbord-main {
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// breakpoint desktop : sidebar visible par défaut
|
||||
@include media-breakpoint-up(lg) {
|
||||
#app-dashbord-sidenav {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
#app-dashbord-main {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
transition: margin 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.sidenav-toggled {
|
||||
#app-dashbord-sidenav {
|
||||
&[dir="ltr"] {
|
||||
transform: translateX(-$sidenav-base-width);
|
||||
}
|
||||
|
||||
&[dir="rtl"] {
|
||||
transform: translateX($sidenav-base-width);
|
||||
}
|
||||
}
|
||||
|
||||
#app-dashbord-main {
|
||||
&[dir="ltr"] {
|
||||
margin-left: -$sidenav-base-width;
|
||||
}
|
||||
|
||||
&[dir="rtl"] {
|
||||
margin-right: -$sidenav-base-width;
|
||||
}
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Sidebar (style SBAdmin-ish)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.app-sidebar.sidenav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex-wrap: nowrap;
|
||||
background-color: rgb(97, 53, 131, 0.7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidenav-menu {
|
||||
flex-grow: 1;
|
||||
|
||||
.nav {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.sidenav-menu-heading {
|
||||
padding: 1.5rem 1rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
position: relative;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav-link-icon {
|
||||
font-size: 0.9rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.sidenav-collapse-arrow {
|
||||
margin-left: auto;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
.sidenav-collapse-arrow {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidenav-menu-nested {
|
||||
margin-left: 1.5rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.osb-scrollable {
|
||||
height: 100%; // occupe toute la hauteur de .app-main
|
||||
max-height: 100%; // évite de dépasser
|
||||
// Pour vérifier sans OverlayScrollbars :
|
||||
//
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.modal {
|
||||
z-index: 1080;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.voice-output-textarea {
|
||||
min-height: 320px;
|
||||
resize: vertical;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#logs-card {
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.app-readme-box,
|
||||
.app-config-box {
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
word-break: break-word;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bs-body-bg);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-config-box {
|
||||
font-size: 0.875rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.app-json-viewer {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.app-json-viewer andypf-json-viewer {
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.app-markdown-body {
|
||||
line-height: 1.6;
|
||||
|
||||
& h1,
|
||||
& h2,
|
||||
& h3,
|
||||
& h4,
|
||||
& h5,
|
||||
& h6 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
& h1:first-child,
|
||||
& h2:first-child,
|
||||
& h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& pre {
|
||||
padding: 0.75rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
& code {
|
||||
color: var(--bs-code-color);
|
||||
}
|
||||
}
|
||||
.app-json-box {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// file: kb_demo_app/frontend/sass/_bootswatch.scss
|
||||
// version: 1
|
||||
|
||||
// Pulse 5.3.8
|
||||
// Bootswatch
|
||||
|
||||
|
||||
// Variables
|
||||
|
||||
// Buttons
|
||||
|
||||
.btn {
|
||||
|
||||
&:focus,
|
||||
&:active,
|
||||
&:active:focus,
|
||||
&.active:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
color: $gray-900;
|
||||
background-color: $white;
|
||||
border-color: #ccc;
|
||||
|
||||
&:hover {
|
||||
color: $gray-900;
|
||||
background-color: $gray-300;
|
||||
border-color: $gray-500;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: tint-color($gray-900, 5%);
|
||||
background-color: $white;
|
||||
border-color: tint-color(#ccc, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&-warning {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&-primary:focus {
|
||||
box-shadow: 0 0 5px tint-color($primary, 10%);
|
||||
}
|
||||
|
||||
&-secondary:focus {
|
||||
box-shadow: 0 0 5px $gray-400;
|
||||
}
|
||||
|
||||
&-success:focus {
|
||||
box-shadow: 0 0 5px tint-color($success, 10%);
|
||||
}
|
||||
|
||||
&-info:focus {
|
||||
box-shadow: 0 0 5px tint-color($info, 10%);
|
||||
}
|
||||
|
||||
&-warning:focus {
|
||||
box-shadow: 0 0 5px tint-color($warning, 10%);
|
||||
}
|
||||
|
||||
&-danger:focus {
|
||||
box-shadow: 0 0 5px tint-color($danger, 10%);
|
||||
}
|
||||
|
||||
&.disabled:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Tables
|
||||
|
||||
.table .thead-dark th {
|
||||
background-color: $secondary;
|
||||
border-color: $table-border-color;
|
||||
}
|
||||
|
||||
.table-primary,
|
||||
.table-secondary,
|
||||
.table-success,
|
||||
.table-warning,
|
||||
.table-danger,
|
||||
.table-info,
|
||||
.table-light {
|
||||
--#{$prefix}table-color: #{$body-color};
|
||||
}
|
||||
|
||||
// Forms
|
||||
|
||||
.form-control:focus {
|
||||
box-shadow: 0 0 5px rgba(100, 65, 164, .4);
|
||||
}
|
||||
|
||||
// Navs
|
||||
|
||||
.nav-tabs {
|
||||
|
||||
.nav-link,
|
||||
.nav-link.active {
|
||||
border-width: 0 0 1px;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.active,
|
||||
.nav-link.active:hover,
|
||||
.nav-link.active:focus {
|
||||
border-bottom: 1px solid $primary;
|
||||
}
|
||||
|
||||
.nav-item+.nav-item {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
&-item.active {
|
||||
color: $gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
// Indicators
|
||||
|
||||
.badge {
|
||||
&.bg-light {
|
||||
color: $dark;
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bars
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
// Containers
|
||||
|
||||
.list-group {
|
||||
&-item {
|
||||
color: rgba(255, 255, 255, .8);
|
||||
|
||||
&.active,
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: 700;
|
||||
|
||||
&:hover {
|
||||
background-color: $list-group-hover-bg;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled:hover {
|
||||
color: $list-group-disabled-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// file: kb_demo_app/frontend/sass/_fontawesome.scss
|
||||
// version: 1
|
||||
|
||||
//@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||
// // customizing $font-path - make sure it points to where your webfonts are stored in your project
|
||||
// $font-path: '../webfonts',
|
||||
//);
|
||||
@use '@fortawesome/fontawesome-free/scss/variables' with (
|
||||
// use fonts from @fortawesome/fontawesome-free
|
||||
$font-path: '@fortawesome/fontawesome-free/webfonts',
|
||||
);
|
||||
// load Font Awesome core
|
||||
@use '@fortawesome/fontawesome-free/scss/fontawesome';
|
||||
|
||||
// load and make available Font Awesome helpers (mixins, functions, and variables)
|
||||
@use '@fortawesome/fontawesome-free/scss/fa' as fa;
|
||||
@use '@fortawesome/fontawesome-free/scss/brands' as fa-brands;
|
||||
@use '@fortawesome/fontawesome-free/scss/regular' as fa-regular;
|
||||
@use '@fortawesome/fontawesome-free/scss/solid' as fa-solid;
|
||||
@@ -0,0 +1,248 @@
|
||||
// file: kb_demo_app/frontend/sass/_simplebar.scss
|
||||
// version: 1
|
||||
|
||||
/* Rtl support */
|
||||
[data-simplebar] {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.simplebar-wrapper {
|
||||
overflow: hidden;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
max-width: inherit;
|
||||
max-height: inherit;
|
||||
}
|
||||
|
||||
.simplebar-mask {
|
||||
direction: inherit;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
// z-index: 0;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.simplebar-offset {
|
||||
direction: inherit !important;
|
||||
box-sizing: inherit !important;
|
||||
resize: none !important;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.simplebar-content-wrapper {
|
||||
direction: inherit;
|
||||
box-sizing: border-box !important;
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-hide-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
position: fixed;
|
||||
left: 0;
|
||||
visibility: hidden;
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.simplebar-content {
|
||||
&:before {
|
||||
content: ' ';
|
||||
display: table;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: ' ';
|
||||
display: table;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-placeholder {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.simplebar-height-auto-observer-wrapper {
|
||||
box-sizing: inherit !important;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 1px;
|
||||
position: relative;
|
||||
float: left;
|
||||
max-height: 1px;
|
||||
overflow: hidden;
|
||||
// z-index: -1;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
flex-grow: inherit;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.simplebar-height-auto-observer {
|
||||
box-sizing: inherit;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 1000%;
|
||||
width: 1000%;
|
||||
min-height: 1px;
|
||||
min-width: 1px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
// z-index: -1;
|
||||
}
|
||||
|
||||
.simplebar-track {
|
||||
// z-index: 1;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-simplebar].simplebar-dragging {
|
||||
pointer-events: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
.simplebar-content {
|
||||
pointer-events: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.simplebar-track {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-scrollbar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-height: 10px;
|
||||
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
background: black;
|
||||
border-radius: 7px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s 0.5s linear;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-scrollbar.simplebar-visible {
|
||||
&:before {
|
||||
opacity: 0.5;
|
||||
transition-delay: 0s;
|
||||
transition-duration: 0s;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-track.simplebar-vertical {
|
||||
top: 0;
|
||||
width: 11px;
|
||||
}
|
||||
|
||||
.simplebar-track.simplebar-horizontal {
|
||||
left: 0;
|
||||
height: 11px;
|
||||
|
||||
.simplebar-scrollbar {
|
||||
right: auto;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
min-height: 0;
|
||||
min-width: 10px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-simplebar-direction='rtl'] {
|
||||
.simplebar-track.simplebar-vertical {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-dummy-scrollbar-size {
|
||||
direction: rtl;
|
||||
position: fixed;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
height: 500px;
|
||||
width: 500px;
|
||||
overflow-y: hidden;
|
||||
overflow-x: scroll;
|
||||
-ms-overflow-style: scrollbar !important;
|
||||
|
||||
>div {
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.simplebar-hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// file: kb_demo_app/frontend/sass/_variables.scss
|
||||
// version: 1
|
||||
|
||||
// Pulse 5.3.8
|
||||
// Bootswatch
|
||||
|
||||
$theme: "pulse" !default;
|
||||
|
||||
//
|
||||
// Color system
|
||||
//
|
||||
|
||||
$white: #fff !default;
|
||||
$gray-100: #fafafa !default;
|
||||
$gray-200: #f9f8fc !default;
|
||||
$gray-300: #ededed !default;
|
||||
$gray-400: #cbc8d0 !default;
|
||||
$gray-500: #adb5bd !default;
|
||||
$gray-600: #868e96 !default;
|
||||
$gray-700: #444 !default;
|
||||
$gray-800: #343a40 !default;
|
||||
$gray-900: #17141f !default;
|
||||
$black: #000 !default;
|
||||
|
||||
$blue: #007bff !default;
|
||||
$indigo: #6610f2 !default;
|
||||
$purple: #593196 !default;
|
||||
$pink: #e83e8c !default;
|
||||
$red: #fc3939 !default;
|
||||
$orange: #fd7e14 !default;
|
||||
$yellow: #efa31d !default;
|
||||
$green: #13b955 !default;
|
||||
$teal: #20c997 !default;
|
||||
$cyan: #009cdc !default;
|
||||
|
||||
$primary: $purple !default;
|
||||
$secondary: #a991d4 !default;
|
||||
$success: $green !default;
|
||||
$info: $cyan !default;
|
||||
$warning: $yellow !default;
|
||||
$danger: $red !default;
|
||||
$light: $gray-200 !default;
|
||||
$dark: $gray-900 !default;
|
||||
|
||||
$min-contrast-ratio: 2.1 !default;
|
||||
|
||||
// Options
|
||||
|
||||
$enable-rounded: false !default;
|
||||
|
||||
// Body
|
||||
|
||||
$body-color: $gray-700 !default;
|
||||
|
||||
// Links
|
||||
|
||||
$link-hover-color: $primary !default;
|
||||
|
||||
// Tables
|
||||
|
||||
$table-color: initial !default;
|
||||
|
||||
$table-border-color: rgba(0, 0, 0, .05) !default;
|
||||
|
||||
// Forms
|
||||
|
||||
$input-focus-border-color: $primary !default;
|
||||
|
||||
// Dropdowns
|
||||
|
||||
$dropdown-link-hover-color: $white !default;
|
||||
$dropdown-link-hover-bg: $primary !default;
|
||||
|
||||
// Navs
|
||||
|
||||
$nav-tabs-border-color: $gray-300 !default;
|
||||
$nav-tabs-link-hover-border-color: $primary !default;
|
||||
|
||||
// Navbar
|
||||
|
||||
$navbar-padding-y: 1.2rem !default;
|
||||
|
||||
// Progress bars
|
||||
|
||||
$progress-bg: $gray-300 !default;
|
||||
$progress-bar-bg: $primary !default;
|
||||
|
||||
// List group
|
||||
|
||||
$list-group-bg: $gray-900 !default;
|
||||
$list-group-border-color: transparent !default;
|
||||
$list-group-hover-bg: lighten($list-group-bg, 10%) !default;
|
||||
$list-group-active-color: $white !default;
|
||||
$list-group-active-bg: $list-group-bg !default;
|
||||
$list-group-disabled-color: lighten($list-group-bg, 30%) !default;
|
||||
@@ -0,0 +1,10 @@
|
||||
// file: kb_demo_app/frontend/sass/main.scss
|
||||
// version: 1
|
||||
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "variables";
|
||||
@import "fontawesome";
|
||||
@import "simplebar";
|
||||
@import "bootstrap/scss/bootstrap";
|
||||
@import "bootswatch";
|
||||
@import "app";
|
||||
@@ -0,0 +1,126 @@
|
||||
// file: kb_app_demo/frontend/sass/splash.scss
|
||||
// version: 3
|
||||
|
||||
@font-face {
|
||||
font-family: 'Dos Amazigh';
|
||||
src: url('../fonts/DOS_Amazigh.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
/* Assure un bon rendu pendant le chargement de la police */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
#splash-container {
|
||||
position: relative;
|
||||
width: 960px;
|
||||
/* Largeur exacte de votre image */
|
||||
height: 637px;
|
||||
/* Hauteur exacte de votre image */
|
||||
opacity: 0;
|
||||
/* Sera animé par JavaScript. */
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
#splash-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#app-name {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: 'Dos Amazigh', sans-serif;
|
||||
/* Application de la police */
|
||||
font-size: 96px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
/* Blanc pour contraste */
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
/* Ombre pour meilleure lisibilité */
|
||||
z-index: 2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#debug-info {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 50%;
|
||||
max-height: 30%;
|
||||
overflow-y: auto;
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 8px;
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#messages-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-height: 30%;
|
||||
overflow-y: auto;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 10px 10px 40px;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.splash-message {
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.splash-message.info {
|
||||
background-color: rgba(33, 150, 243, 0.2);
|
||||
}
|
||||
|
||||
.splash-message.warning {
|
||||
background-color: rgba(255, 152, 0, 0.2);
|
||||
}
|
||||
|
||||
.splash-message.error {
|
||||
background-color: rgba(244, 67, 54, 0.2);
|
||||
}
|
||||
|
||||
.splash-message.success {
|
||||
background-color: rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!-- file: kb_app_demo/frontend/splash.html -->
|
||||
<!-- version: 2 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Loading ... Khadhroony Bot2</title>
|
||||
<link rel="stylesheet" href="sass/splash.scss" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="splash-container" style="opacity: 0;">
|
||||
<img id="splash-image" src="imgs/splash.png" alt="Application Loading" />
|
||||
<div id="app-name">Khadhroony</div>
|
||||
<div id="debug-info"></div>
|
||||
<div id="messages-container"></div>
|
||||
</div>
|
||||
<script type="module" src="ts/splash.ts" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,267 @@
|
||||
// file: kb_app_demo/frontend/ts/demo_backfill.ts
|
||||
// version: 5
|
||||
|
||||
import * as bootstrap from "bootstrap";
|
||||
import "simplebar";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import type { DemoBackfillOptionsPayload } from "./bindings/kb_app_demo/demo_backfill/DemoBackfillOptionsPayload";
|
||||
import type { DemoBackfillProgressPayload } from "./bindings/kb_app_demo/demo_backfill/DemoBackfillProgressPayload";
|
||||
import type { DemoBackfillRequest } from "./bindings/kb_app_demo/demo_backfill/DemoBackfillRequest";
|
||||
import type { DemoBackfillSummaryPayload } from "./bindings/kb_app_demo/demo_backfill/DemoBackfillSummaryPayload";
|
||||
|
||||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
|
||||
const logLines: string[] = [];
|
||||
const maximumLogLines = 1000;
|
||||
let running = false;
|
||||
|
||||
function element<T extends HTMLElement>(selector: string): T {
|
||||
const value = document.querySelector<T>(selector);
|
||||
if (!value) {
|
||||
throw new Error(`Missing UI element: ${selector}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function inputValue(selector: string): string {
|
||||
return element<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(selector).value.trim();
|
||||
}
|
||||
|
||||
function integerValue(selector: string): number {
|
||||
const parsed = Number.parseInt(inputValue(selector), 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`Valeur numérique invalide pour ${selector}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
function decodesToSolanaAddress(value: string): boolean {
|
||||
if (value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let numericValue = 0n;
|
||||
for (const character of value) {
|
||||
const digit = base58Alphabet.indexOf(character);
|
||||
if (digit < 0) {
|
||||
return false;
|
||||
}
|
||||
numericValue = numericValue * 58n + BigInt(digit);
|
||||
}
|
||||
let decodedNonZeroBytes = 0;
|
||||
let remaining = numericValue;
|
||||
while (remaining > 0n) {
|
||||
decodedNonZeroBytes += 1;
|
||||
remaining >>= 8n;
|
||||
}
|
||||
let leadingZeroBytes = 0;
|
||||
for (const character of value) {
|
||||
if (character !== "1") {
|
||||
break;
|
||||
}
|
||||
leadingZeroBytes += 1;
|
||||
}
|
||||
return leadingZeroBytes + decodedNonZeroBytes === 32;
|
||||
}
|
||||
|
||||
function appendLog(payload: DemoBackfillProgressPayload | { timestamp: string; level: string; message: string }): void {
|
||||
const progress = "completed" in payload && payload.completed !== null && payload.total !== null
|
||||
? ` [${payload.completed}/${payload.total}]`
|
||||
: "";
|
||||
logLines.push(`${payload.timestamp} ${payload.level.toUpperCase()}${progress} ${payload.message}`);
|
||||
while (logLines.length > maximumLogLines) {
|
||||
logLines.shift();
|
||||
}
|
||||
element<HTMLTextAreaElement>("#backfillLogOutput").value = logLines.join("\n");
|
||||
element<HTMLTextAreaElement>("#backfillLogOutput").scrollTop = element<HTMLTextAreaElement>("#backfillLogOutput").scrollHeight;
|
||||
}
|
||||
|
||||
function setRunning(value: boolean): void {
|
||||
running = value;
|
||||
document.querySelectorAll<HTMLButtonElement>(".backfill-start-button").forEach(button => {
|
||||
button.disabled = value;
|
||||
});
|
||||
element<HTMLButtonElement>("#cancelBackfillButton").disabled = !value;
|
||||
const badge = element<HTMLElement>("#backfillStatusBadge");
|
||||
badge.textContent = value ? "Backfill en cours" : "Prêt";
|
||||
badge.className = value ? "badge text-bg-warning" : "badge text-bg-success";
|
||||
}
|
||||
|
||||
function commonRequest(mode: string): DemoBackfillRequest {
|
||||
return {
|
||||
role: inputValue("#backfillRoleSelect"),
|
||||
commitment: inputValue("#backfillCommitmentSelect"),
|
||||
mode,
|
||||
signaturesText: null,
|
||||
address: null,
|
||||
anchorSignature: null,
|
||||
direction: null,
|
||||
limit: 1,
|
||||
pageSize: integerValue("#backfillPageSizeInput"),
|
||||
maxPages: integerValue("#backfillMaxPagesInput"),
|
||||
maxConcurrentRequests: integerValue("#backfillConcurrencyInput"),
|
||||
maxRetries: integerValue("#backfillRetriesInput"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequest(mode: string): DemoBackfillRequest {
|
||||
const request = commonRequest(mode);
|
||||
if (mode === "explicit_signatures") {
|
||||
request.signaturesText = element<HTMLTextAreaElement>("#explicitSignaturesTextarea").value;
|
||||
return request;
|
||||
}
|
||||
request.address = inputValue(`#${mode}AddressInput`);
|
||||
const anchorSignature = inputValue(`#${mode}AnchorInput`);
|
||||
request.anchorSignature = anchorSignature.length > 0 ? anchorSignature : null;
|
||||
request.direction = inputValue(`#${mode}DirectionSelect`);
|
||||
request.limit = integerValue(`#${mode}LimitInput`);
|
||||
return request;
|
||||
}
|
||||
|
||||
function validationMessage(request: DemoBackfillRequest): string | null {
|
||||
if (request.mode === "explicit_signatures") {
|
||||
const signatureCount = request.signaturesText
|
||||
? request.signaturesText.split(/\r?\n/).filter(value => value.trim().length > 0).length
|
||||
: 0;
|
||||
if (signatureCount === 0) {
|
||||
return "Ajouter au moins une signature explicite avant de lancer le backfill.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!request.address || request.address.trim().length === 0) {
|
||||
return "L’adresse est obligatoire pour ce mode de backfill.";
|
||||
}
|
||||
if (!decodesToSolanaAddress(request.address.trim())) {
|
||||
return "L’adresse Solana doit être une valeur Base58 décodant exactement sur 32 octets.";
|
||||
}
|
||||
if (request.direction === "after" && (!request.anchorSignature || request.anchorSignature.trim().length === 0)) {
|
||||
return "La signature d’ancrage est obligatoire pour rechercher des transactions plus récentes.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function reportValidationWarning(message: string): void {
|
||||
element<HTMLTextAreaElement>("#backfillSummaryOutput").value = JSON.stringify({ validation: message }, null, 2);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "warn", message });
|
||||
frontendDebug("kb_app_demo.frontend.demo_backfill", `Backfill request rejected locally: ${message}`);
|
||||
}
|
||||
|
||||
|
||||
function synchronizeAnchorRequirement(mode: "program" | "token" | "pool"): void {
|
||||
const direction = element<HTMLSelectElement>(`#${mode}DirectionSelect`).value;
|
||||
const anchor = element<HTMLInputElement>(`#${mode}AnchorInput`);
|
||||
const required = direction === "after";
|
||||
anchor.required = required;
|
||||
anchor.placeholder = required
|
||||
? "Signature obligatoire pour rechercher après cette transaction"
|
||||
: "Vide : commencer depuis les transactions les plus récentes";
|
||||
}
|
||||
|
||||
async function executeBackfill(mode: string): Promise<void> {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const request = buildRequest(mode);
|
||||
const invalid = validationMessage(request);
|
||||
if (invalid) {
|
||||
reportValidationWarning(invalid);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
element<HTMLTextAreaElement>("#backfillSummaryOutput").value = "Exécution en cours...";
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "info", message: `Démarrage du mode ${mode}` });
|
||||
const summary = await invoke<DemoBackfillSummaryPayload>("demo_backfill_execute", { request });
|
||||
element<HTMLTextAreaElement>("#backfillSummaryOutput").value = JSON.stringify(summary, null, 2);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: summary.cancelled ? "warn" : "info",
|
||||
message: `Campagne terminée : completed=${summary.candidatesCompleted}, cancelled=${summary.candidatesCancelled}, notStarted=${summary.candidatesNotStarted}, inserted=${summary.canonicalInserted}, existing=${summary.existingSkipped}, missing=${summary.missing}, failed=${summary.failed}`,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLTextAreaElement>("#backfillSummaryOutput").value = JSON.stringify({ error: message }, null, 2);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", message });
|
||||
frontendError("kb_app_demo.frontend.demo_backfill", `Backfill failed: ${message}`);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelBackfill(): Promise<void> {
|
||||
try {
|
||||
const accepted = await invoke<boolean>("demo_backfill_cancel");
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: accepted ? "warn" : "info",
|
||||
message: accepted ? "Demande d'arrêt envoyée." : "Aucune campagne active.",
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.demo_backfill", `Cancellation failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions(): Promise<void> {
|
||||
const options = await invoke<DemoBackfillOptionsPayload>("demo_backfill_options");
|
||||
const roleSelect = element<HTMLSelectElement>("#backfillRoleSelect");
|
||||
roleSelect.replaceChildren();
|
||||
for (const role of options.roles) {
|
||||
const option = document.createElement("option");
|
||||
option.value = role.role;
|
||||
option.textContent = `${role.role} — ${role.providers.join(", ")}`;
|
||||
option.selected = role.role === options.defaultRole;
|
||||
roleSelect.append(option);
|
||||
}
|
||||
if (options.roles.length === 0) {
|
||||
const option = document.createElement("option");
|
||||
option.value = "";
|
||||
option.textContent = "Aucun rôle compatible";
|
||||
roleSelect.append(option);
|
||||
}
|
||||
element<HTMLSelectElement>("#backfillCommitmentSelect").value = options.defaultCommitment;
|
||||
element<HTMLInputElement>("#backfillPageSizeInput").value = String(options.defaultPageSize);
|
||||
element<HTMLInputElement>("#backfillMaxPagesInput").value = String(options.defaultMaxPages);
|
||||
element<HTMLInputElement>("#backfillConcurrencyInput").value = String(options.defaultMaxConcurrentRequests);
|
||||
element<HTMLInputElement>("#backfillRetriesInput").value = String(options.defaultMaxRetries);
|
||||
setRunning(options.running);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
installFrontendConsoleBridge("kb_app_demo.frontend.demo_backfill");
|
||||
frontendDebug("kb_app_demo.frontend.demo_backfill", "backfill demo window loaded");
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(item => new bootstrap.Tooltip(item));
|
||||
for (const mode of ["program", "token", "pool"] as const) {
|
||||
const direction = element<HTMLSelectElement>(`#${mode}DirectionSelect`);
|
||||
direction.addEventListener("change", () => synchronizeAnchorRequirement(mode));
|
||||
synchronizeAnchorRequirement(mode);
|
||||
}
|
||||
document.querySelectorAll<HTMLButtonElement>(".backfill-start-button").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
const mode = button.dataset.backfillMode;
|
||||
if (mode) {
|
||||
void executeBackfill(mode);
|
||||
}
|
||||
});
|
||||
});
|
||||
element<HTMLButtonElement>("#cancelBackfillButton").addEventListener("click", () => {
|
||||
void cancelBackfill();
|
||||
});
|
||||
element<HTMLButtonElement>("#clearBackfillLogButton").addEventListener("click", () => {
|
||||
logLines.length = 0;
|
||||
element<HTMLTextAreaElement>("#backfillLogOutput").value = "";
|
||||
});
|
||||
void listen<DemoBackfillProgressPayload>("demo-backfill-progress", event => {
|
||||
appendLog(event.payload);
|
||||
});
|
||||
void loadOptions().catch(caughtError => {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", message });
|
||||
frontendError("kb_app_demo.frontend.demo_backfill", `Options loading failed: ${message}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// file: kb_app_demo/frontend/ts/demo_config.ts
|
||||
// version: 4
|
||||
|
||||
import * as bootstrap from "bootstrap";
|
||||
import "simplebar";
|
||||
import "@andypf/json-viewer";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import type { DemoConfigPayload } from "./bindings/kb_app_demo/demo_config/DemoConfigPayload";
|
||||
|
||||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
|
||||
function setText(selector: string, value: string): void {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (element) {
|
||||
element.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function renderJsonViewer(selector: string, value: unknown): void {
|
||||
const container = document.querySelector<HTMLElement>(selector);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const serialized = JSON.stringify(value, null, 2) ?? "null";
|
||||
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", serialized);
|
||||
container.textContent = "";
|
||||
container.appendChild(viewer);
|
||||
}
|
||||
|
||||
function parseSchemaJson(schemaJson: string): unknown {
|
||||
try {
|
||||
return JSON.parse(schemaJson) as unknown;
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
return {
|
||||
error: `Schema JSON parsing failed: ${message}`,
|
||||
raw: schemaJson,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDemoConfig(): Promise<void> {
|
||||
try {
|
||||
const payload = await invoke<DemoConfigPayload>("load_demo_config");
|
||||
setText("#configPath", payload.config_path);
|
||||
setText("#activeProfileName", payload.active_profile_name);
|
||||
setText("#activeEnvironment", payload.environment);
|
||||
renderJsonViewer("#activeProfileJson", payload.active_profile);
|
||||
renderJsonViewer("#fullConfigJson", payload.app_config);
|
||||
renderJsonViewer("#schemaJson", parseSchemaJson(payload.schema_json));
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
setText("#activeProfileJson", `Erreur pendant le chargement de la configuration : ${message}`);
|
||||
frontendError("kb_app_demo.frontend.demo_config", `Configuration loading failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
installFrontendConsoleBridge("kb_app_demo.frontend.demo_config");
|
||||
|
||||
frontendDebug("kb_app_demo.frontend.demo_config", "configuration demo window loaded");
|
||||
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
||||
const toastElList = document.querySelectorAll('.toast');
|
||||
Array.from(toastElList).map(toastEl => new bootstrap.Toast(toastEl));
|
||||
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]');
|
||||
Array.from(popoverTriggerList).map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl));
|
||||
|
||||
await loadDemoConfig();
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
// file: kb_app_demo/frontend/ts/demo_core_extraction.ts
|
||||
// version: 4
|
||||
|
||||
import * as bootstrap from "bootstrap";
|
||||
import "simplebar";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import type { DemoCoreExtractionOptionsPayload } from "./bindings/kb_app_demo/demo_core_extraction/DemoCoreExtractionOptionsPayload";
|
||||
import type { DemoCoreExtractionProgressPayload } from "./bindings/kb_app_demo/demo_core_extraction/DemoCoreExtractionProgressPayload";
|
||||
import type { DemoCoreExtractionRequest } from "./bindings/kb_app_demo/demo_core_extraction/DemoCoreExtractionRequest";
|
||||
import type { DemoCoreExtractionSummaryPayload } from "./bindings/kb_app_demo/demo_core_extraction/DemoCoreExtractionSummaryPayload";
|
||||
|
||||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
|
||||
const logLines: string[] = [];
|
||||
const maximumLogLines = 1000;
|
||||
let running = false;
|
||||
|
||||
function element<T extends HTMLElement>(selector: string): T {
|
||||
const value = document.querySelector<T>(selector);
|
||||
if (!value) {
|
||||
throw new Error(`Missing UI element: ${selector}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function inputValue(selector: string): string {
|
||||
return element<HTMLInputElement | HTMLTextAreaElement>(selector).value.trim();
|
||||
}
|
||||
|
||||
function integerValue(selector: string): number {
|
||||
const parsed = Number.parseInt(inputValue(selector), 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`Valeur numérique invalide pour ${selector}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function nullableIntegerValue(selector: string): number | null {
|
||||
const value = inputValue(selector);
|
||||
if (value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`Valeur numérique invalide pour ${selector}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function appendLog(payload: DemoCoreExtractionProgressPayload | { timestamp: string; level: string; message: string }): void {
|
||||
const progress = "completed" in payload ? ` [${payload.completed}/${payload.total}]` : "";
|
||||
logLines.push(`${payload.timestamp} ${payload.level.toUpperCase()}${progress} ${payload.message}`);
|
||||
while (logLines.length > maximumLogLines) {
|
||||
logLines.shift();
|
||||
}
|
||||
const output = element<HTMLTextAreaElement>("#coreExtractionLogOutput");
|
||||
output.value = logLines.join("\n");
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function setRunning(value: boolean): void {
|
||||
running = value;
|
||||
document.querySelectorAll<HTMLButtonElement>(".core-extraction-start-button").forEach(button => {
|
||||
button.disabled = value;
|
||||
});
|
||||
element<HTMLButtonElement>("#cancelCoreExtractionButton").disabled = !value;
|
||||
const badge = element<HTMLElement>("#coreExtractionStatusBadge");
|
||||
badge.textContent = value ? "Extraction en cours" : "Prêt";
|
||||
badge.className = value ? "badge text-bg-warning" : "badge text-bg-success";
|
||||
}
|
||||
|
||||
function buildRequest(mode: string): DemoCoreExtractionRequest {
|
||||
return {
|
||||
mode,
|
||||
signaturesText: mode === "signatures" ? element<HTMLTextAreaElement>("#coreExtractionSignaturesTextarea").value : null,
|
||||
programId: mode === "program_id" ? inputValue("#coreExtractionProgramIdInput") : null,
|
||||
minSlot: mode === "slot_range" ? nullableIntegerValue("#coreExtractionMinSlotInput") : null,
|
||||
maxSlot: mode === "slot_range" ? nullableIntegerValue("#coreExtractionMaxSlotInput") : null,
|
||||
limit: integerValue("#coreExtractionLimitInput"),
|
||||
maxConcurrentExtractions: integerValue("#coreExtractionConcurrencyInput"),
|
||||
forceReplay: element<HTMLInputElement>("#coreExtractionForceReplayInput").checked,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeCoreExtraction(mode: string): Promise<void> {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const request = buildRequest(mode);
|
||||
setRunning(true);
|
||||
element<HTMLTextAreaElement>("#coreExtractionSummaryOutput").value = "Exécution en cours...";
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "info", message: `Démarrage du mode ${mode}` });
|
||||
const summary = await invoke<DemoCoreExtractionSummaryPayload>("demo_core_extraction_execute", { request });
|
||||
element<HTMLTextAreaElement>("#coreExtractionSummaryOutput").value = JSON.stringify(summary, null, 2);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: summary.cancelled || Number(summary.failed) > 0 ? "warn" : "info",
|
||||
message: `Campagne terminée : extracted=${summary.extracted}, skipped=${summary.skipped}, failed=${summary.failed}, cancelled=${summary.cancelledCandidates}, notStarted=${summary.notStarted}`,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLTextAreaElement>("#coreExtractionSummaryOutput").value = JSON.stringify({ error: message }, null, 2);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", message });
|
||||
frontendError("kb_app_demo.frontend.demo_core_extraction", `Core extraction failed: ${message}`);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelCoreExtraction(): Promise<void> {
|
||||
try {
|
||||
const accepted = await invoke<boolean>("demo_core_extraction_cancel");
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: accepted ? "warn" : "info",
|
||||
message: accepted ? "Demande d'arrêt envoyée." : "Aucune campagne active.",
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.demo_core_extraction", `Cancellation failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function openDiagnostics(command: "open_demo_sql_pg_raw_window" | "open_demo_sql_pg_core_window" | "open_demo_sql_replay_candidates_window", label: string): Promise<void> {
|
||||
try {
|
||||
await invoke(command);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "info", message: `${label} ouvert.` });
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", message });
|
||||
frontendError("kb_app_demo.frontend.demo_core_extraction", `${label} opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions(): Promise<void> {
|
||||
const options = await invoke<DemoCoreExtractionOptionsPayload>("demo_core_extraction_options");
|
||||
element<HTMLElement>("#coreExtractionVersionBadge").textContent = `Version ${options.processorVersion}`;
|
||||
element<HTMLInputElement>("#coreExtractionLimitInput").value = String(options.defaultLimit);
|
||||
element<HTMLInputElement>("#coreExtractionConcurrencyInput").value = String(options.defaultMaxConcurrentExtractions);
|
||||
setRunning(options.running);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
installFrontendConsoleBridge("kb_app_demo.frontend.demo_core_extraction");
|
||||
frontendDebug("kb_app_demo.frontend.demo_core_extraction", "core extraction demo window loaded");
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(item => new bootstrap.Tooltip(item));
|
||||
document.querySelectorAll<HTMLButtonElement>(".core-extraction-start-button").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
const mode = button.dataset.coreExtractionMode;
|
||||
if (mode) {
|
||||
void executeCoreExtraction(mode);
|
||||
}
|
||||
});
|
||||
});
|
||||
element<HTMLButtonElement>("#openRawDiagnosticsButton").addEventListener("click", () => {
|
||||
void openDiagnostics("open_demo_sql_pg_raw_window", "Diagnostic raw");
|
||||
});
|
||||
element<HTMLButtonElement>("#openCoreDiagnosticsButton").addEventListener("click", () => {
|
||||
void openDiagnostics("open_demo_sql_pg_core_window", "Diagnostic core");
|
||||
});
|
||||
element<HTMLButtonElement>("#openReplayCandidatesButton").addEventListener("click", () => {
|
||||
void openDiagnostics("open_demo_sql_replay_candidates_window", "Sélecteur de candidats replay");
|
||||
});
|
||||
element<HTMLButtonElement>("#cancelCoreExtractionButton").addEventListener("click", () => {
|
||||
void cancelCoreExtraction();
|
||||
});
|
||||
element<HTMLButtonElement>("#clearCoreExtractionLogButton").addEventListener("click", () => {
|
||||
logLines.length = 0;
|
||||
element<HTMLTextAreaElement>("#coreExtractionLogOutput").value = "";
|
||||
});
|
||||
void listen<DemoCoreExtractionProgressPayload>("demo-core-extraction-progress", event => {
|
||||
appendLog(event.payload);
|
||||
});
|
||||
void loadOptions().catch(caughtError => {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", message });
|
||||
frontendError("kb_app_demo.frontend.demo_core_extraction", `Options loading failed: ${message}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
// file: kb_app_demo/frontend/ts/demo_decode_replay.ts
|
||||
// version: 5
|
||||
|
||||
import * as bootstrap from "bootstrap";
|
||||
import "simplebar";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import type { DemoDecodeDiagnosticsPayload } from "./bindings/kb_app_demo/demo_decode_replay/DemoDecodeDiagnosticsPayload";
|
||||
import type { DemoDecodeReplayOptionsPayload } from "./bindings/kb_app_demo/demo_decode_replay/DemoDecodeReplayOptionsPayload";
|
||||
import type { DemoDecodeReplayProgressPayload } from "./bindings/kb_app_demo/demo_decode_replay/DemoDecodeReplayProgressPayload";
|
||||
import type { DemoDecodeReplayRequest } from "./bindings/kb_app_demo/demo_decode_replay/DemoDecodeReplayRequest";
|
||||
import type { DemoDecodeReplaySummaryPayload } from "./bindings/kb_app_demo/demo_decode_replay/DemoDecodeReplaySummaryPayload";
|
||||
import type { DemoTransactionAnnotationRequest } from "./bindings/kb_app_demo/demo_decode_replay/DemoTransactionAnnotationRequest";
|
||||
import type { DemoTransactionAnnotationRow } from "./bindings/kb_app_demo/demo_decode_replay/DemoTransactionAnnotationRow";
|
||||
|
||||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
|
||||
const logLines: string[] = [];
|
||||
let running = false;
|
||||
let materializationAvailable = false;
|
||||
|
||||
function element<T extends HTMLElement>(selector: string): T {
|
||||
const value = document.querySelector<T>(selector);
|
||||
if (!value) {
|
||||
throw new Error(`Missing UI element: ${selector}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function inputValue(selector: string): string {
|
||||
return element<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(selector).value.trim();
|
||||
}
|
||||
|
||||
function integerValue(selector: string): number {
|
||||
const value = Number.parseInt(inputValue(selector), 10);
|
||||
if (!Number.isFinite(value) || value < 1) {
|
||||
throw new Error(`Valeur numérique invalide pour ${selector}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function appendLog(payload: DemoDecodeReplayProgressPayload | { timestamp: string; level: string; message: string }): void {
|
||||
const progress = "completed" in payload ? ` [${payload.completed}/${payload.total}]` : "";
|
||||
const campaign = "campaignId" in payload ? ` [${payload.campaignId}]` : "";
|
||||
logLines.push(`${payload.timestamp} ${payload.level.toUpperCase()}${campaign}${progress} ${payload.message}`);
|
||||
while (logLines.length > 1000) {
|
||||
logLines.shift();
|
||||
}
|
||||
const output = element<HTMLTextAreaElement>("#decodeReplayLogOutput");
|
||||
output.value = logLines.join("\n");
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function explicitSignatureCount(): number {
|
||||
return element<HTMLTextAreaElement>("#decodeReplaySignaturesTextarea")
|
||||
.value
|
||||
.split(/\r?\n/)
|
||||
.filter(value => value.trim().length > 0)
|
||||
.length;
|
||||
}
|
||||
|
||||
function updateReplayScopeControls(): void {
|
||||
const signaturesPresent = explicitSignatureCount() > 0;
|
||||
const incompleteSignatures = inputValue("#decodeReplayStateSelect") === "incomplete_signatures";
|
||||
const allMatchingInput = element<HTMLInputElement>("#decodeReplayAllSignaturesInput");
|
||||
const forceInput = element<HTMLInputElement>("#decodeReplayForceInput");
|
||||
const materializeInput = element<HTMLInputElement>("#decodeReplayMaterializeInput");
|
||||
if (!running && (signaturesPresent || incompleteSignatures) && allMatchingInput.checked) {
|
||||
allMatchingInput.checked = false;
|
||||
}
|
||||
allMatchingInput.disabled = running || signaturesPresent || incompleteSignatures;
|
||||
const forceScopeMissing = !signaturesPresent && !allMatchingInput.checked;
|
||||
forceInput.disabled = running || forceScopeMissing || incompleteSignatures;
|
||||
if (!running && (forceScopeMissing || incompleteSignatures)) {
|
||||
forceInput.checked = false;
|
||||
}
|
||||
materializeInput.disabled = running || !materializationAvailable;
|
||||
if (!running && !materializationAvailable) {
|
||||
materializeInput.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setRunning(value: boolean): void {
|
||||
running = value;
|
||||
element<HTMLButtonElement>("#startDecodeReplayButton").disabled = value;
|
||||
element<HTMLButtonElement>("#cancelDecodeReplayButton").disabled = !value;
|
||||
const badge = element<HTMLElement>("#decodeReplayStatusBadge");
|
||||
badge.textContent = value ? "Replay en cours" : "Prêt";
|
||||
badge.className = value ? "badge text-bg-warning" : "badge text-bg-success";
|
||||
updateReplayScopeControls();
|
||||
}
|
||||
|
||||
function selectedDecoderNames(): string[] {
|
||||
return Array.from(document.querySelectorAll<HTMLInputElement>(".decode-replay-decoder-input:checked"))
|
||||
.map(input => input.value);
|
||||
}
|
||||
|
||||
function buildRequest(): DemoDecodeReplayRequest {
|
||||
return {
|
||||
signaturesText: element<HTMLTextAreaElement>("#decodeReplaySignaturesTextarea").value || null,
|
||||
programId: inputValue("#decodeReplayProgramIdInput") || null,
|
||||
instructionState: inputValue("#decodeReplayStateSelect"),
|
||||
instructionPathsText: element<HTMLTextAreaElement>("#decodeReplayPathsTextarea").value || null,
|
||||
decoderNames: selectedDecoderNames(),
|
||||
limit: integerValue("#decodeReplayLimitInput"),
|
||||
maxConcurrentInputs: integerValue("#decodeReplayConcurrencyInput"),
|
||||
allCompatible: element<HTMLInputElement>("#decodeReplayAllCompatibleInput").checked,
|
||||
forceReplay: element<HTMLInputElement>("#decodeReplayForceInput").checked,
|
||||
forceReplayAllMatching: element<HTMLInputElement>("#decodeReplayAllSignaturesInput").checked,
|
||||
materializeAfterDecode: element<HTMLInputElement>("#decodeReplayMaterializeInput").checked,
|
||||
};
|
||||
}
|
||||
|
||||
function validationMessage(request: DemoDecodeReplayRequest): string | null {
|
||||
const signatureCount = request.signaturesText
|
||||
? request.signaturesText.split(/\r?\n/).filter(value => value.trim().length > 0).length
|
||||
: 0;
|
||||
if (request.decoderNames.length === 0) {
|
||||
return "Sélectionner au moins un décodeur.";
|
||||
}
|
||||
if (request.instructionState === "incomplete_signatures" && request.forceReplay) {
|
||||
return "Le mode signatures incomplètes rejoue son périmètre sans Force replay global.";
|
||||
}
|
||||
if (request.forceReplay && signatureCount === 0 && !request.forceReplayAllMatching) {
|
||||
return "Le force replay nécessite des signatures explicites ou l’autorisation Toutes les signatures.";
|
||||
}
|
||||
if (request.forceReplayAllMatching && signatureCount > 0) {
|
||||
return "Le mode « Toutes les signatures » ne peut pas être combiné avec une liste de signatures explicites.";
|
||||
}
|
||||
if (request.forceReplayAllMatching && !request.forceReplay) {
|
||||
return "Le mode « Toutes les signatures » est uniquement disponible avec Force replay.";
|
||||
}
|
||||
if (request.materializeAfterDecode && !materializationAvailable) {
|
||||
return "Aucun matérialiseur n’est actuellement enregistré dans cette application.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function reportValidationWarning(message: string): void {
|
||||
element<HTMLTextAreaElement>("#decodeReplaySummaryOutput").value = JSON.stringify({ validation: message }, null, 2);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "warn", message });
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", `Decode replay request rejected locally: ${message}`);
|
||||
}
|
||||
|
||||
async function executeDecodeReplay(): Promise<void> {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const request = buildRequest();
|
||||
const invalid = validationMessage(request);
|
||||
if (invalid) {
|
||||
reportValidationWarning(invalid);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
element<HTMLTextAreaElement>("#decodeReplaySummaryOutput").value = "Exécution en cours...";
|
||||
const signatureCount = request.signaturesText
|
||||
? request.signaturesText.split(/\r?\n/).filter(value => value.trim().length > 0).length
|
||||
: 0;
|
||||
const requestSummary = `signatures=${signatureCount}, programId=${request.programId ?? "auto"}, state=${request.instructionState}, decoders=${request.decoderNames.join(",")}, limit=${request.limit}, concurrency=${request.maxConcurrentInputs}, allCompatible=${request.allCompatible}, forceReplay=${request.forceReplay}, allSignatures=${request.forceReplayAllMatching}, materialize=${request.materializeAfterDecode}`;
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "info", message: `Démarrage du replay de décodage contextualisé : ${requestSummary}` });
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", `Invoke demo_decode_replay_execute ${requestSummary}`);
|
||||
const summary = await invoke<DemoDecodeReplaySummaryPayload>("demo_decode_replay_execute", { request });
|
||||
element<HTMLTextAreaElement>("#decodeReplaySummaryOutput").value = JSON.stringify(summary, null, 2);
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", `Invoke demo_decode_replay_execute completed summary=${JSON.stringify(summary)}`);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: summary.cancelled || summary.failedInputs > 0 ? "warn" : "info",
|
||||
message: `Campagne ${summary.campaignId} terminée : completed=${summary.completed}, unmatched=${summary.unmatched}, failed=${summary.failedInputs}, notStarted=${summary.notStarted}`,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLTextAreaElement>("#decodeReplaySummaryOutput").value = JSON.stringify({ error: message }, null, 2);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", message });
|
||||
frontendError("kb_app_demo.frontend.demo_decode_replay", `Decode replay failed: ${message}`);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelDecodeReplay(): Promise<void> {
|
||||
try {
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", "Invoke demo_decode_replay_cancel");
|
||||
const accepted = await invoke<boolean>("demo_decode_replay_cancel");
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", `Invoke demo_decode_replay_cancel completed accepted=${accepted}`);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: accepted ? "warn" : "info",
|
||||
message: accepted ? "Demande d'arrêt envoyée." : "Aucune campagne active.",
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.demo_decode_replay", `Cancellation failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiagnostics(): Promise<void> {
|
||||
try {
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", "Invoke demo_decode_replay_diagnostics");
|
||||
const diagnostics = await invoke<DemoDecodeDiagnosticsPayload>("demo_decode_replay_diagnostics");
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", `Invoke demo_decode_replay_diagnostics completed tables=${diagnostics.tables.length} coverage=${diagnostics.coverage.length}`);
|
||||
element<HTMLTextAreaElement>("#decodeReplayDiagnosticsOutput").value = JSON.stringify(diagnostics, null, 2);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLTextAreaElement>("#decodeReplayDiagnosticsOutput").value = JSON.stringify({ error: message }, null, 2);
|
||||
frontendError("kb_app_demo.frontend.demo_decode_replay", `Diagnostics loading failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function tableCell(value: string, title?: string): HTMLTableCellElement {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = value;
|
||||
if (title) {
|
||||
cell.title = title;
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
function shortened(value: string, visible: number): string {
|
||||
return value.length > visible ? `${value.slice(0, visible)}…` : value;
|
||||
}
|
||||
|
||||
function renderAnnotations(rows: DemoTransactionAnnotationRow[]): void {
|
||||
const body = element<HTMLTableSectionElement>("#annotationJournalBody");
|
||||
body.replaceChildren();
|
||||
for (const row of rows) {
|
||||
const tableRow = document.createElement("tr");
|
||||
tableRow.append(
|
||||
tableCell(row.slot),
|
||||
tableCell(shortened(row.signature, 18), row.signature),
|
||||
tableCell(row.instructionPath),
|
||||
tableCell(row.generation, row.programId),
|
||||
tableCell(row.text),
|
||||
tableCell(String(row.payloadLengthBytes)),
|
||||
tableCell(row.verifiedSigners.join(", ") || "—"),
|
||||
tableCell(`${shortened(row.payloadSha256, 12)} · ${row.decoder}`, `${row.payloadSha256}\n${row.idempotenceKey}\ntransaction_annotations@${row.processorVersion}`),
|
||||
);
|
||||
body.append(tableRow);
|
||||
}
|
||||
element<HTMLElement>("#annotationJournalStatus").textContent = `${rows.length} annotation(s) committed chargée(s).`;
|
||||
}
|
||||
|
||||
async function loadAnnotations(): Promise<void> {
|
||||
const request: DemoTransactionAnnotationRequest = {
|
||||
signatureContains: inputValue("#annotationSignatureInput") || null,
|
||||
limit: integerValue("#annotationLimitInput"),
|
||||
};
|
||||
try {
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", `Invoke demo_decode_replay_annotations limit=${request.limit}`);
|
||||
const rows = await invoke<DemoTransactionAnnotationRow[]>("demo_decode_replay_annotations", { request });
|
||||
renderAnnotations(rows);
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", `Invoke demo_decode_replay_annotations completed rows=${rows.length}`);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLElement>("#annotationJournalStatus").textContent = `Erreur : ${message}`;
|
||||
frontendError("kb_app_demo.frontend.demo_decode_replay", `Annotation journal loading failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openWindow(command: "open_demo_sql_pg_core_window" | "open_demo_sql_replay_candidates_window"): Promise<void> {
|
||||
try {
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", `Invoke ${command}`);
|
||||
await invoke(command);
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", `Invoke ${command} completed`);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.demo_decode_replay", `Window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions(): Promise<void> {
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", "Invoke demo_decode_replay_options");
|
||||
const options = await invoke<DemoDecodeReplayOptionsPayload>("demo_decode_replay_options");
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", `Invoke demo_decode_replay_options completed options=${JSON.stringify(options)}`);
|
||||
element<HTMLElement>("#decodeReplayVersionBadge").textContent = `Pipeline ${options.pipelineVersion}`;
|
||||
element<HTMLInputElement>("#decodeReplayLimitInput").value = String(options.defaultLimit);
|
||||
element<HTMLInputElement>("#decodeReplayConcurrencyInput").value = String(options.defaultMaxConcurrentInputs);
|
||||
const container = element<HTMLElement>("#decodeReplayDecoderList");
|
||||
container.innerHTML = "";
|
||||
for (const decoder of options.decoders) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "form-check border rounded p-3 ps-5 mb-2";
|
||||
const input = document.createElement("input");
|
||||
input.className = "form-check-input decode-replay-decoder-input";
|
||||
input.type = "checkbox";
|
||||
input.value = decoder.name;
|
||||
input.id = `decode-${decoder.name}`;
|
||||
input.checked = true;
|
||||
const label = document.createElement("label");
|
||||
label.className = "form-check-label";
|
||||
label.htmlFor = input.id;
|
||||
label.textContent = `${decoder.name} v${decoder.version} — ${decoder.programIds.length} programmes`;
|
||||
wrapper.append(input, label);
|
||||
container.append(wrapper);
|
||||
}
|
||||
materializationAvailable = options.materializerNames.length > 0;
|
||||
const materializeHelp = element<HTMLElement>("#decodeReplayMaterializeHelp");
|
||||
materializeHelp.textContent = materializationAvailable
|
||||
? `Matérialiseurs disponibles : ${options.materializerNames.join(", ")}`
|
||||
: "Aucun matérialiseur enregistré : option désactivée.";
|
||||
setRunning(options.running);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
installFrontendConsoleBridge("kb_app_demo.frontend.demo_decode_replay");
|
||||
frontendDebug("kb_app_demo.frontend.demo_decode_replay", "decode replay demo window loaded");
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(item => new bootstrap.Tooltip(item));
|
||||
element<HTMLTextAreaElement>("#decodeReplaySignaturesTextarea").addEventListener("input", () => updateReplayScopeControls());
|
||||
element<HTMLSelectElement>("#decodeReplayStateSelect").addEventListener("change", () => updateReplayScopeControls());
|
||||
element<HTMLInputElement>("#decodeReplayAllSignaturesInput").addEventListener("change", event => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
if (input.checked) {
|
||||
element<HTMLInputElement>("#decodeReplayForceInput").checked = true;
|
||||
}
|
||||
updateReplayScopeControls();
|
||||
});
|
||||
element<HTMLInputElement>("#decodeReplayForceInput").addEventListener("change", event => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
if (!input.checked) {
|
||||
element<HTMLInputElement>("#decodeReplayAllSignaturesInput").checked = false;
|
||||
}
|
||||
updateReplayScopeControls();
|
||||
});
|
||||
element<HTMLButtonElement>("#startDecodeReplayButton").addEventListener("click", () => void executeDecodeReplay());
|
||||
element<HTMLButtonElement>("#cancelDecodeReplayButton").addEventListener("click", () => void cancelDecodeReplay());
|
||||
element<HTMLButtonElement>("#loadDecodeDiagnosticsButton").addEventListener("click", () => void loadDiagnostics());
|
||||
element<HTMLButtonElement>("#loadAnnotationsButton").addEventListener("click", () => void loadAnnotations());
|
||||
element<HTMLButtonElement>("#openReplayCandidatesFromDecodeButton").addEventListener("click", () => void openWindow("open_demo_sql_replay_candidates_window"));
|
||||
element<HTMLButtonElement>("#openCoreDiagnosticsFromDecodeButton").addEventListener("click", () => void openWindow("open_demo_sql_pg_core_window"));
|
||||
void listen<DemoDecodeReplayProgressPayload>("demo-decode-replay-progress", event => appendLog(event.payload));
|
||||
void loadOptions().catch(caughtError => {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", message });
|
||||
frontendError("kb_app_demo.frontend.demo_decode_replay", `Options loading failed: ${message}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// file: kb_app_demo/frontend/ts/demo_execution_solana_core.ts
|
||||
// version: 6
|
||||
|
||||
import * as bootstrap from "bootstrap";
|
||||
import "simplebar";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { frontendDebug, frontendError, frontendWarn, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import type { DemoExecutionSolanaCoreGeneratedRecipientPayload } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreGeneratedRecipientPayload";
|
||||
import type { DemoExecutionSolanaCoreOptionsPayload } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreOptionsPayload";
|
||||
import type { DemoExecutionSolanaCoreProfileOption } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreProfileOption";
|
||||
import type { DemoExecutionSolanaCoreProgressPayload } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreProgressPayload";
|
||||
import type { DemoExecutionSolanaCoreRequest } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreRequest";
|
||||
import type { DemoExecutionSolanaCoreSummaryPayload } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreSummaryPayload";
|
||||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
const logLines: string[] = [];
|
||||
let running = false;
|
||||
let profileOptions: DemoExecutionSolanaCoreProfileOption[] = [];
|
||||
function element<T extends HTMLElement>(selector: string): T { const value=document.querySelector<T>(selector); if(!value){throw new Error(`Missing UI element: ${selector}`);} return value; }
|
||||
function integerValue(selector: string): number { const value=Number.parseInt(element<HTMLInputElement>(selector).value,10); if(!Number.isFinite(value)||value<0){throw new Error(`Valeur numérique invalide pour ${selector}`);} return value; }
|
||||
function selectedProfile(): DemoExecutionSolanaCoreProfileOption|null { const name=element<HTMLSelectElement>("#executionProfileSelect").value; return profileOptions.find(profile=>profile.name===name)??null; }
|
||||
function appendLog(payload: DemoExecutionSolanaCoreProgressPayload|{timestamp:string;level:string;stage:string;message:string;signature?:string|null}):void { const signature=payload.signature?` signature=${payload.signature}`:""; logLines.push(`${payload.timestamp} ${payload.level.toUpperCase()} [${payload.stage}] ${payload.message}${signature}`); const output=element<HTMLTextAreaElement>("#executionLogOutput"); output.value=logLines.join("\n"); output.scrollTop=output.scrollHeight; }
|
||||
function setRunning(value:boolean):void { running=value; const profile=selectedProfile(); element<HTMLButtonElement>("#simulateExecutionButton").disabled=value||!profile; element<HTMLButtonElement>("#submitExecutionButton").disabled=value||!profile||!profile.sendEnabled; element<HTMLButtonElement>("#generateRecipientButton").disabled=value; element<HTMLButtonElement>("#cancelExecutionButton").disabled=!value; const badge=element<HTMLElement>("#executionStatusBadge"); badge.textContent=value?"Exécution en cours":"Prêt"; badge.className=value?"badge text-bg-warning":"badge text-bg-success"; }
|
||||
function updateProfileHelp():void { const profile=selectedProfile(); const help=element<HTMLElement>("#executionProfileHelp"); if(!profile){help.textContent="Aucun profil Devnet compatible.";return;} help.textContent=`wallet=${profile.walletAlias}, spendMax=${profile.maxSpendLamports}, airdropMax=${profile.maxAirdropLamports}, send=${profile.sendEnabled?"enabled":"disabled"}`; element<HTMLInputElement>("#executionAirdropInput").max=String(profile.maxAirdropLamports); element<HTMLInputElement>("#executionLamportsInput").max=String(profile.maxSpendLamports); setRunning(running); }
|
||||
function buildRequest(submit:boolean):DemoExecutionSolanaCoreRequest { const recipient=element<HTMLInputElement>("#executionRecipientInput").value.trim(); if(recipient.length===0){throw new Error("Le destinataire est obligatoire.");} return {profileName:element<HTMLSelectElement>("#executionProfileSelect").value,recipient,lamports:integerValue("#executionLamportsInput"),airdropLamports:integerValue("#executionAirdropInput"),submit,operatorConfirmed:element<HTMLInputElement>("#executionOperatorConfirmedInput").checked,forcePostValidationReplay:element<HTMLInputElement>("#executionForceReplayInput").checked,materializeAfterDecode:element<HTMLInputElement>("#executionMaterializeInput").checked}; }
|
||||
function displaySummary(summary:DemoExecutionSolanaCoreSummaryPayload):void { element<HTMLTextAreaElement>("#executionSummaryOutput").value=JSON.stringify(summary,null,2); element<HTMLTextAreaElement>("#executionPlanOutput").value=summary.planJson; element<HTMLTextAreaElement>("#executionSimulationOutput").value=summary.simulationJson; element<HTMLTextAreaElement>("#executionDiagnosticsOutput").value=summary.diagnosticsJson; }
|
||||
async function execute(submit:boolean):Promise<void>{if(running){return;}try{const request=buildRequest(submit);if(submit&&!request.operatorConfirmed){frontendWarn("kb_app_demo.frontend.demo_execution_solana_core","Submission blocked: operator confirmation missing");return;}setRunning(true);const summary=await invoke<DemoExecutionSolanaCoreSummaryPayload>("demo_execution_solana_core_execute",{request});displaySummary(summary);}catch(caughtError){const message=caughtError instanceof Error?caughtError.message:String(caughtError);frontendError("kb_app_demo.frontend.demo_execution_solana_core",`Execution failed: ${message}`);}finally{setRunning(false);}}
|
||||
async function loadOptions():Promise<void>{const options=await invoke<DemoExecutionSolanaCoreOptionsPayload>("demo_execution_solana_core_options");profileOptions=options.profiles;const select=element<HTMLSelectElement>("#executionProfileSelect");select.replaceChildren();for(const profile of profileOptions){const option=document.createElement("option");option.value=profile.name;option.textContent=profile.name;option.selected=profile.name===options.defaultProfileName;select.append(option);}element<HTMLInputElement>("#executionLamportsInput").value=String(options.defaultTransferLamports);updateProfileHelp();setRunning(options.running);}
|
||||
document.addEventListener("DOMContentLoaded",async()=>{installFrontendConsoleBridge("kb_app_demo.frontend.demo_execution_solana_core");frontendDebug("kb_app_demo.frontend.demo_execution_solana_core","Solana Core execution window loaded");await listen<DemoExecutionSolanaCoreProgressPayload>("demo-execution-solana-core-progress",event=>appendLog(event.payload));element<HTMLSelectElement>("#executionProfileSelect").addEventListener("change",updateProfileHelp);element<HTMLButtonElement>("#generateRecipientButton").addEventListener("click",async()=>{const payload=await invoke<DemoExecutionSolanaCoreGeneratedRecipientPayload>("demo_execution_solana_core_generate_recipient");element<HTMLInputElement>("#executionRecipientInput").value=payload.publicKey;});element<HTMLButtonElement>("#simulateExecutionButton").addEventListener("click",()=>void execute(false));element<HTMLButtonElement>("#submitExecutionButton").addEventListener("click",()=>void execute(true));element<HTMLButtonElement>("#cancelExecutionButton").addEventListener("click",()=>void invoke("demo_execution_solana_core_cancel"));element<HTMLButtonElement>("#clearExecutionLogButton").addEventListener("click",()=>{logLines.length=0;element<HTMLTextAreaElement>("#executionLogOutput").value="";});try{await loadOptions();}catch(caughtError){const message=caughtError instanceof Error?caughtError.message:String(caughtError);frontendError("kb_app_demo.frontend.demo_execution_solana_core",`Options loading failed: ${message}`);}});
|
||||
@@ -0,0 +1,881 @@
|
||||
// file: kb_app_demo/frontend/ts/demo_execution_spl.ts
|
||||
// version: 2
|
||||
|
||||
import * as bootstrap from "bootstrap";
|
||||
import "simplebar";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { frontendDebug, frontendError, frontendWarn, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import type { DemoExecutionSolanaCoreGeneratedRecipientPayload } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreGeneratedRecipientPayload";
|
||||
import type { DemoExecutionSolanaCoreOptionsPayload } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreOptionsPayload";
|
||||
import type { DemoExecutionSolanaCoreProfileOption } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreProfileOption";
|
||||
import type { DemoExecutionSolanaCoreProgressPayload } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreProgressPayload";
|
||||
import type { DemoExecutionSolanaCoreRequest } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreRequest";
|
||||
import type { DemoExecutionSolanaCoreSummaryPayload } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreSummaryPayload";
|
||||
import type { DemoExecutionMemoRequest } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionMemoRequest";
|
||||
import type { DemoExecutionMemoSummaryPayload } from "./bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionMemoSummaryPayload";
|
||||
import type { DemoExecutionSplTokenRequest } from "./bindings/kb_app_demo/demo_spl_token/DemoExecutionSplTokenRequest";
|
||||
import type { DemoExecutionSplTokenSummaryPayload } from "./bindings/kb_app_demo/demo_spl_token/DemoExecutionSplTokenSummaryPayload";
|
||||
import type { DemoSplTokenJournalRequest } from "./bindings/kb_app_demo/demo_spl_token/DemoSplTokenJournalRequest";
|
||||
import type { DemoSplTokenJournalRow } from "./bindings/kb_app_demo/demo_spl_token/DemoSplTokenJournalRow";
|
||||
import type { DemoExecutionSplAtaRequest } from "./bindings/kb_app_demo/demo_spl_ata/DemoExecutionSplAtaRequest";
|
||||
import type { DemoExecutionSplAtaSummaryPayload } from "./bindings/kb_app_demo/demo_spl_ata/DemoExecutionSplAtaSummaryPayload";
|
||||
import type { DemoSplAtaDerivationPayload } from "./bindings/kb_app_demo/demo_spl_ata/DemoSplAtaDerivationPayload";
|
||||
import type { DemoSplAtaDerivationRequest } from "./bindings/kb_app_demo/demo_spl_ata/DemoSplAtaDerivationRequest";
|
||||
import type { DemoSplAtaJournalRequest } from "./bindings/kb_app_demo/demo_spl_ata/DemoSplAtaJournalRequest";
|
||||
import type { DemoSplAtaJournalRow } from "./bindings/kb_app_demo/demo_spl_ata/DemoSplAtaJournalRow";
|
||||
import type { DemoExecutionSplToken2022Request } from "./bindings/kb_app_demo/demo_spl_token_2022/DemoExecutionSplToken2022Request";
|
||||
import type { DemoExecutionSplToken2022SummaryPayload } from "./bindings/kb_app_demo/demo_spl_token_2022/DemoExecutionSplToken2022SummaryPayload";
|
||||
import type { DemoSplToken2022FixturePayload } from "./bindings/kb_app_demo/demo_spl_token_2022/DemoSplToken2022FixturePayload";
|
||||
|
||||
|
||||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
|
||||
const logLines: string[] = [];
|
||||
const maximumLogLines = 1200;
|
||||
let running = false;
|
||||
let profileOptions: DemoExecutionSolanaCoreProfileOption[] = [];
|
||||
let token2022Fixture: DemoSplToken2022FixturePayload | null = null;
|
||||
|
||||
function element<T extends HTMLElement>(selector: string): T {
|
||||
const value = document.querySelector<T>(selector);
|
||||
if (!value) {
|
||||
throw new Error(`Missing UI element: ${selector}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(selector: string): number {
|
||||
const value = Number.parseInt(element<HTMLInputElement>(selector).value, 10);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`Valeur numérique invalide pour ${selector}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function appendLog(payload: DemoExecutionSolanaCoreProgressPayload | { timestamp: string; level: string; stage: string; message: string; signature?: string | null }): void {
|
||||
const signature = payload.signature ? ` signature=${payload.signature}` : "";
|
||||
logLines.push(`${payload.timestamp} ${payload.level.toUpperCase()} [${payload.stage}] ${payload.message}${signature}`);
|
||||
while (logLines.length > maximumLogLines) {
|
||||
logLines.shift();
|
||||
}
|
||||
const output = element<HTMLTextAreaElement>("#executionLogOutput");
|
||||
output.value = logLines.join("\n");
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function setRunning(value: boolean): void {
|
||||
running = value;
|
||||
const profile = selectedProfile();
|
||||
element<HTMLButtonElement>("#simulateExecutionButton").disabled = value || !profile;
|
||||
element<HTMLButtonElement>("#submitExecutionButton").disabled = value || !profile || !profile.sendEnabled;
|
||||
element<HTMLButtonElement>("#simulateMemoButton").disabled = value || !profile;
|
||||
element<HTMLButtonElement>("#submitMemoButton").disabled = value || !profile || !profile.sendEnabled;
|
||||
element<HTMLButtonElement>("#simulateTokenButton").disabled = value || !profile;
|
||||
element<HTMLButtonElement>("#submitTokenButton").disabled = value || !profile || !profile.sendEnabled;
|
||||
element<HTMLButtonElement>("#deriveAtaButton").disabled = value || !profile;
|
||||
element<HTMLButtonElement>("#simulateAtaButton").disabled = value || !profile;
|
||||
element<HTMLButtonElement>("#submitAtaButton").disabled = value || !profile || !profile.sendEnabled;
|
||||
element<HTMLButtonElement>("#simulateToken2022Button").disabled = value || !profile;
|
||||
element<HTMLButtonElement>("#submitToken2022Button").disabled = value || !profile || !profile.sendEnabled;
|
||||
element<HTMLButtonElement>("#loadToken2022FixtureButton").disabled = value || !profile;
|
||||
element<HTMLButtonElement>("#generateRecipientButton").disabled = value;
|
||||
element<HTMLButtonElement>("#cancelExecutionButton").disabled = !value;
|
||||
const badge = element<HTMLElement>("#executionStatusBadge");
|
||||
badge.textContent = value ? "Exécution en cours" : "Prêt";
|
||||
badge.className = value ? "badge text-bg-warning" : "badge text-bg-success";
|
||||
}
|
||||
|
||||
function selectedProfile(): DemoExecutionSolanaCoreProfileOption | null {
|
||||
const name = element<HTMLSelectElement>("#executionProfileSelect").value;
|
||||
return profileOptions.find(profile => profile.name === name) ?? null;
|
||||
}
|
||||
|
||||
function updateProfileHelp(): void {
|
||||
const profile = selectedProfile();
|
||||
const help = element<HTMLElement>("#executionProfileHelp");
|
||||
if (!profile) {
|
||||
help.textContent = "Aucun profil Devnet compatible.";
|
||||
return;
|
||||
}
|
||||
help.textContent = `wallet=${profile.walletAlias}, spendMax=${profile.maxSpendLamports}, airdropMax=${profile.maxAirdropLamports}, send=${profile.sendEnabled ? "enabled" : "disabled"}`;
|
||||
element<HTMLInputElement>("#executionAirdropInput").max = String(profile.maxAirdropLamports);
|
||||
element<HTMLInputElement>("#executionLamportsInput").max = String(profile.maxSpendLamports);
|
||||
setRunning(running);
|
||||
}
|
||||
|
||||
function buildRequest(submit: boolean): DemoExecutionSolanaCoreRequest {
|
||||
const recipient = element<HTMLInputElement>("#executionRecipientInput").value.trim();
|
||||
if (recipient.length === 0) {
|
||||
throw new Error("Le destinataire est obligatoire.");
|
||||
}
|
||||
return {
|
||||
profileName: element<HTMLSelectElement>("#executionProfileSelect").value,
|
||||
recipient,
|
||||
lamports: integerValue("#executionLamportsInput"),
|
||||
airdropLamports: integerValue("#executionAirdropInput"),
|
||||
submit,
|
||||
operatorConfirmed: element<HTMLInputElement>("#executionOperatorConfirmedInput").checked,
|
||||
forcePostValidationReplay: element<HTMLInputElement>("#executionForceReplayInput").checked,
|
||||
materializeAfterDecode: element<HTMLInputElement>("#executionMaterializeInput").checked,
|
||||
};
|
||||
}
|
||||
|
||||
function displaySummary(summary: DemoExecutionSolanaCoreSummaryPayload): void {
|
||||
const concise = {
|
||||
profileName: summary.profileName,
|
||||
cluster: summary.cluster,
|
||||
genesisHash: summary.genesisHash,
|
||||
walletPublicKey: summary.walletPublicKey,
|
||||
recipient: summary.recipient,
|
||||
recipientExistedBefore: summary.recipientExistedBefore,
|
||||
recipientBalanceBeforeLamports: summary.recipientBalanceBeforeLamports,
|
||||
recipientMinimumBalanceLamports: summary.recipientMinimumBalanceLamports,
|
||||
balanceBeforeLamports: summary.balanceBeforeLamports,
|
||||
balanceAfterFundingLamports: summary.balanceAfterFundingLamports,
|
||||
feeLamports: summary.feeLamports,
|
||||
simulationSuccess: summary.simulationSuccess,
|
||||
simulationError: summary.simulationError,
|
||||
airdropSignature: summary.airdropSignature,
|
||||
transactionSignature: summary.transactionSignature,
|
||||
confirmationStatus: summary.confirmationStatus,
|
||||
canonicalInserted: summary.canonicalInserted,
|
||||
coreExtracted: summary.coreExtracted,
|
||||
decodeCompleted: summary.decodeCompleted,
|
||||
decodeFailedInputs: summary.decodeFailedInputs,
|
||||
};
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = JSON.stringify(concise, null, 2);
|
||||
element<HTMLTextAreaElement>("#executionPlanOutput").value = summary.planJson;
|
||||
element<HTMLTextAreaElement>("#executionSimulationOutput").value = summary.simulationJson;
|
||||
element<HTMLTextAreaElement>("#executionDiagnosticsOutput").value = summary.diagnosticsJson;
|
||||
}
|
||||
|
||||
function buildMemoRequest(submit: boolean): DemoExecutionMemoRequest {
|
||||
return {
|
||||
profileName: element<HTMLSelectElement>("#executionProfileSelect").value,
|
||||
message: element<HTMLTextAreaElement>("#memoMessageInput").value,
|
||||
includeWalletAsMemoSigner: element<HTMLInputElement>("#memoWalletSignerInput").checked,
|
||||
submit,
|
||||
operatorConfirmed: element<HTMLInputElement>("#executionOperatorConfirmedInput").checked,
|
||||
forcePostValidationReplay: element<HTMLInputElement>("#executionForceReplayInput").checked,
|
||||
};
|
||||
}
|
||||
|
||||
function displayMemoSummary(summary: DemoExecutionMemoSummaryPayload): void {
|
||||
const concise = {
|
||||
operation: "spl_memo_v4.add_memo",
|
||||
profileName: summary.profileName,
|
||||
cluster: summary.cluster,
|
||||
genesisHash: summary.genesisHash,
|
||||
walletPublicKey: summary.walletPublicKey,
|
||||
balanceLamports: summary.balanceLamports,
|
||||
feeLamports: summary.feeLamports,
|
||||
simulationSuccess: summary.simulationSuccess,
|
||||
simulationError: summary.simulationError,
|
||||
transactionSignature: summary.transactionSignature,
|
||||
confirmationStatus: summary.confirmationStatus,
|
||||
canonicalInserted: summary.canonicalInserted,
|
||||
coreExtracted: summary.coreExtracted,
|
||||
decodeReplayed: summary.decodeReplayed,
|
||||
materialized: summary.materialized,
|
||||
annotationCount: summary.annotationCount,
|
||||
idempotenceValidated: summary.idempotenceValidated,
|
||||
};
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = JSON.stringify(concise, null, 2);
|
||||
element<HTMLTextAreaElement>("#executionPlanOutput").value = summary.planJson;
|
||||
element<HTMLTextAreaElement>("#executionSimulationOutput").value = summary.simulationJson;
|
||||
element<HTMLTextAreaElement>("#executionDiagnosticsOutput").value = summary.diagnosticsJson;
|
||||
}
|
||||
|
||||
function requiredText(selector: string, label: string): string {
|
||||
const value = element<HTMLInputElement>(selector).value.trim();
|
||||
if (value.length === 0) {
|
||||
throw new Error(`${label} est obligatoire.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalText(selector: string): string | null {
|
||||
const value = element<HTMLInputElement>(selector).value.trim();
|
||||
return value.length === 0 ? null : value;
|
||||
}
|
||||
|
||||
function buildTokenRequest(submit: boolean): DemoExecutionSplTokenRequest {
|
||||
const amountRaw = requiredText("#tokenAmountRawInput", "Le montant brut");
|
||||
if (!/^[0-9]+$/.test(amountRaw)) {
|
||||
throw new Error("Le montant brut doit être un entier décimal non signé.");
|
||||
}
|
||||
const decimals = integerValue("#tokenDecimalsInput");
|
||||
if (decimals > 255) {
|
||||
throw new Error("Les decimals doivent être compris entre 0 et 255.");
|
||||
}
|
||||
return {
|
||||
profileName: element<HTMLSelectElement>("#executionProfileSelect").value,
|
||||
source: requiredText("#tokenSourceInput", "Le compte source"),
|
||||
mint: requiredText("#tokenMintInput", "Le mint"),
|
||||
destination: requiredText("#tokenDestinationInput", "Le compte destination"),
|
||||
authority: requiredText("#tokenAuthorityInput", "L’autorité"),
|
||||
amountRaw,
|
||||
decimals,
|
||||
submit,
|
||||
operatorConfirmed: element<HTMLInputElement>("#executionOperatorConfirmedInput").checked,
|
||||
forcePostValidationReplay: element<HTMLInputElement>("#executionForceReplayInput").checked,
|
||||
};
|
||||
}
|
||||
|
||||
function displayTokenSummary(summary: DemoExecutionSplTokenSummaryPayload): void {
|
||||
const concise = {
|
||||
operation: summary.operation,
|
||||
profileName: summary.profileName,
|
||||
cluster: summary.cluster,
|
||||
genesisHash: summary.genesisHash,
|
||||
walletPublicKey: summary.walletPublicKey,
|
||||
balanceLamports: summary.balanceLamports,
|
||||
feeLamports: summary.feeLamports,
|
||||
amountRaw: summary.amountRaw,
|
||||
decimals: summary.decimals,
|
||||
readinessStatus: summary.readinessStatus,
|
||||
simulationSuccess: summary.simulationSuccess,
|
||||
simulationError: summary.simulationError,
|
||||
transactionSignature: summary.transactionSignature,
|
||||
confirmationStatus: summary.confirmationStatus,
|
||||
canonicalInserted: summary.canonicalInserted,
|
||||
coreExtracted: summary.coreExtracted,
|
||||
decodeReplayed: summary.decodeReplayed,
|
||||
materializationCount: summary.materializationCount,
|
||||
idempotenceValidated: summary.idempotenceValidated,
|
||||
};
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = JSON.stringify(concise, null, 2);
|
||||
element<HTMLTextAreaElement>("#executionPlanOutput").value = `PRÉFLIGHT\n${summary.readinessJson}\n\nPLAN\n${summary.planJson}`;
|
||||
element<HTMLTextAreaElement>("#executionSimulationOutput").value = summary.simulationJson;
|
||||
element<HTMLTextAreaElement>("#executionDiagnosticsOutput").value = summary.diagnosticsJson;
|
||||
}
|
||||
|
||||
function ataDerivationRequest(): DemoSplAtaDerivationRequest {
|
||||
return {
|
||||
profileName: element<HTMLSelectElement>("#executionProfileSelect").value,
|
||||
mint: requiredText("#ataMintInput", "Le mint ATA"),
|
||||
tokenProgram: element<HTMLSelectElement>("#ataTokenProgramSelect").value,
|
||||
};
|
||||
}
|
||||
|
||||
async function deriveAta(): Promise<DemoSplAtaDerivationPayload> {
|
||||
const payload = await invoke<DemoSplAtaDerivationPayload>("demo_spl_ata_derive", {
|
||||
request: ataDerivationRequest(),
|
||||
});
|
||||
element<HTMLInputElement>("#ataPayerInput").value = payload.payer;
|
||||
element<HTMLInputElement>("#ataWalletOwnerInput").value = payload.walletOwner;
|
||||
element<HTMLInputElement>("#ataDerivedInput").value = payload.associatedTokenAccount;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function displayAtaSummary(summary: DemoExecutionSplAtaSummaryPayload): void {
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = JSON.stringify({
|
||||
operation: summary.operation,
|
||||
profileName: summary.profileName,
|
||||
walletPublicKey: summary.walletPublicKey,
|
||||
tokenProgramId: summary.tokenProgramId,
|
||||
associatedTokenAccount: summary.associatedTokenAccount,
|
||||
balanceLamports: summary.balanceLamports,
|
||||
feeLamports: summary.feeLamports,
|
||||
readinessStatus: summary.readinessStatus,
|
||||
simulationSuccess: summary.simulationSuccess,
|
||||
simulationError: summary.simulationError,
|
||||
transactionSignature: summary.transactionSignature,
|
||||
confirmationStatus: summary.confirmationStatus,
|
||||
materializationCount: summary.materializationCount,
|
||||
idempotenceValidated: summary.idempotenceValidated,
|
||||
}, null, 2);
|
||||
element<HTMLTextAreaElement>("#executionPlanOutput").value = `PRÉFLIGHT\n${summary.readinessJson}\n\nPLAN\n${summary.planJson}`;
|
||||
element<HTMLTextAreaElement>("#executionSimulationOutput").value = summary.simulationJson;
|
||||
element<HTMLTextAreaElement>("#executionDiagnosticsOutput").value = summary.diagnosticsJson;
|
||||
}
|
||||
|
||||
async function executeAta(submit: boolean): Promise<void> {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (submit && !element<HTMLInputElement>("#executionOperatorConfirmedInput").checked) {
|
||||
throw new Error("La confirmation opérateur est obligatoire avant signature ATA.");
|
||||
}
|
||||
const derivation = await deriveAta();
|
||||
const request: DemoExecutionSplAtaRequest = {
|
||||
profileName: element<HTMLSelectElement>("#executionProfileSelect").value,
|
||||
walletOwner: derivation.walletOwner,
|
||||
mint: requiredText("#ataMintInput", "Le mint ATA"),
|
||||
tokenProgram: element<HTMLSelectElement>("#ataTokenProgramSelect").value,
|
||||
mode: element<HTMLSelectElement>("#ataModeSelect").value,
|
||||
submit,
|
||||
operatorConfirmed: element<HTMLInputElement>("#executionOperatorConfirmedInput").checked,
|
||||
forcePostValidationReplay: element<HTMLInputElement>("#executionForceReplayInput").checked,
|
||||
};
|
||||
setRunning(true);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
stage: submit ? "ata_submit" : "ata_simulation",
|
||||
message: `${request.mode} ${request.tokenProgram}, ATA=${derivation.associatedTokenAccount}`,
|
||||
});
|
||||
const summary = await invoke<DemoExecutionSplAtaSummaryPayload>("demo_execution_spl_ata_execute", { request });
|
||||
displayAtaSummary(summary);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: summary.simulationSuccess && (!submit || summary.idempotenceValidated) ? "info" : "error",
|
||||
stage: "ata_completed",
|
||||
message: `preflight=${summary.readinessStatus}, simulation=${summary.simulationSuccess}, confirmation=${summary.confirmationStatus ?? "none"}, materializations=${summary.materializationCount}, idempotence=${summary.idempotenceValidated}`,
|
||||
signature: summary.transactionSignature,
|
||||
});
|
||||
if (summary.transactionSignature) {
|
||||
element<HTMLInputElement>("#ataJournalSignatureInput").value = summary.transactionSignature;
|
||||
element<HTMLInputElement>("#ataJournalMintInput").value = request.mint;
|
||||
element<HTMLInputElement>("#ataJournalAddressInput").value = summary.associatedTokenAccount;
|
||||
await loadAtaJournal();
|
||||
}
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", stage: "ata_failed", message });
|
||||
frontendError("kb_app_demo.frontend.demo_execution_spl", `ATA execution failed: ${message}`);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function ataJournalRequest(): DemoSplAtaJournalRequest {
|
||||
return {
|
||||
profileName: element<HTMLSelectElement>("#executionProfileSelect").value,
|
||||
signatureContains: optionalText("#ataJournalSignatureInput"),
|
||||
mint: optionalText("#ataJournalMintInput"),
|
||||
associatedTokenAccount: optionalText("#ataJournalAddressInput"),
|
||||
operation: optionalText("#ataJournalOperationInput"),
|
||||
limit: integerValue("#ataJournalLimitInput"),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAtaJournal(): Promise<void> {
|
||||
const button = element<HTMLButtonElement>("#refreshAtaJournalButton");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const rows = await invoke<DemoSplAtaJournalRow[]>("demo_spl_ata_journal", { request: ataJournalRequest() });
|
||||
element<HTMLTextAreaElement>("#ataJournalOutput").value = JSON.stringify(rows.map(row => ({
|
||||
...row,
|
||||
payloadJson: JSON.parse(row.payloadJson) as unknown,
|
||||
})), null, 2);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLTextAreaElement>("#ataJournalOutput").value = JSON.stringify({ error: message }, null, 2);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(submit: boolean): Promise<void> {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const request = buildRequest(submit);
|
||||
if (submit && !request.operatorConfirmed) {
|
||||
const message = "La confirmation opérateur doit être cochée avant signature et envoi.";
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = JSON.stringify({ warning: message }, null, 2);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "warn",
|
||||
stage: "policy",
|
||||
message,
|
||||
});
|
||||
frontendWarn("kb_app_demo.frontend.demo_execution_spl", `Execution blocked by policy: ${message}`);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = "Exécution en cours...";
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
stage: submit ? "submit" : "simulation",
|
||||
message: submit ? "Démarrage du parcours Devnet complet." : "Démarrage de la simulation exacte.",
|
||||
});
|
||||
const summary = await invoke<DemoExecutionSolanaCoreSummaryPayload>("demo_execution_solana_core_execute", { request });
|
||||
displaySummary(summary);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: summary.simulationSuccess ? "info" : "error",
|
||||
stage: "completed",
|
||||
message: `simulation=${summary.simulationSuccess}, confirmation=${summary.confirmationStatus ?? "none"}, canonical=${summary.canonicalInserted ?? 0}, core=${summary.coreExtracted ?? 0}, decode=${summary.decodeCompleted ?? 0}`,
|
||||
signature: summary.transactionSignature,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = JSON.stringify({ error: message }, null, 2);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", stage: "failed", message });
|
||||
frontendError("kb_app_demo.frontend.demo_execution_spl", `Execution failed: ${message}`);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeMemo(submit: boolean): Promise<void> {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const request = buildMemoRequest(submit);
|
||||
if (submit && !request.operatorConfirmed) {
|
||||
const message = "La confirmation opérateur doit être cochée avant signature et envoi du Memo.";
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "warn", stage: "memo_policy", message });
|
||||
frontendWarn("kb_app_demo.frontend.demo_execution_spl", `Memo execution blocked by policy: ${message}`);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = "Exécution Memo en cours...";
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
stage: submit ? "memo_submit" : "memo_simulation",
|
||||
message: submit ? "Démarrage du parcours Memo v4 Devnet complet." : "Démarrage de la simulation Memo v4 exacte.",
|
||||
});
|
||||
const summary = await invoke<DemoExecutionMemoSummaryPayload>("demo_execution_spl_memo_execute", { request });
|
||||
displayMemoSummary(summary);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: summary.simulationSuccess && (!submit || summary.materialized) ? "info" : "error",
|
||||
stage: "memo_completed",
|
||||
message: `simulation=${summary.simulationSuccess}, confirmation=${summary.confirmationStatus ?? "none"}, annotation=${summary.annotationCount}, idempotence=${summary.idempotenceValidated}`,
|
||||
signature: summary.transactionSignature,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = JSON.stringify({ error: message }, null, 2);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", stage: "memo_failed", message });
|
||||
frontendError("kb_app_demo.frontend.demo_execution_spl", `Memo execution failed: ${message}`);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeToken(submit: boolean): Promise<void> {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const request = buildTokenRequest(submit);
|
||||
if (submit && !request.operatorConfirmed) {
|
||||
const message = "La confirmation opérateur doit être cochée avant signature et envoi Token.";
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "warn", stage: "spl_token_policy", message });
|
||||
frontendWarn("kb_app_demo.frontend.demo_execution_spl", `Token execution blocked by policy: ${message}`);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = "Préflight et exécution SPL Token en cours...";
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
stage: submit ? "spl_token_submit" : "spl_token_simulation",
|
||||
message: submit ? "Démarrage du TransferChecked Devnet complet." : "Démarrage du préflight et de la simulation TransferChecked.",
|
||||
});
|
||||
const summary = await invoke<DemoExecutionSplTokenSummaryPayload>("demo_execution_spl_token_execute", { request });
|
||||
displayTokenSummary(summary);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: summary.simulationSuccess && (!submit || summary.idempotenceValidated) ? "info" : "error",
|
||||
stage: "spl_token_completed",
|
||||
message: `preflight=${summary.readinessStatus}, simulation=${summary.simulationSuccess}, confirmation=${summary.confirmationStatus ?? "none"}, materializations=${summary.materializationCount}, idempotence=${summary.idempotenceValidated}`,
|
||||
signature: summary.transactionSignature,
|
||||
});
|
||||
if (summary.transactionSignature) {
|
||||
element<HTMLInputElement>("#tokenJournalSignatureInput").value = summary.transactionSignature;
|
||||
await loadTokenJournal();
|
||||
}
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = JSON.stringify({ error: message }, null, 2);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", stage: "spl_token_failed", message });
|
||||
frontendError("kb_app_demo.frontend.demo_execution_spl", `Token execution failed: ${message}`);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function tokenJournalRequest(): DemoSplTokenJournalRequest {
|
||||
return {
|
||||
profileName: element<HTMLSelectElement>("#executionProfileSelect").value,
|
||||
signatureContains: optionalText("#tokenJournalSignatureInput"),
|
||||
mint: optionalText("#tokenJournalMintInput"),
|
||||
account: optionalText("#tokenJournalAccountInput"),
|
||||
family: element<HTMLSelectElement>("#tokenJournalFamilySelect").value || null,
|
||||
operation: optionalText("#tokenJournalOperationInput"),
|
||||
limit: integerValue("#tokenJournalLimitInput"),
|
||||
};
|
||||
}
|
||||
|
||||
function renderTokenJournal(rows: DemoSplTokenJournalRow[]): void {
|
||||
const body = element<HTMLTableSectionElement>("#tokenJournalRows");
|
||||
body.replaceChildren();
|
||||
if (rows.length === 0) {
|
||||
const row = document.createElement("tr");
|
||||
const cell = document.createElement("td");
|
||||
cell.colSpan = 5;
|
||||
cell.className = "text-body-secondary";
|
||||
cell.textContent = "Aucun événement correspondant.";
|
||||
row.append(cell);
|
||||
body.append(row);
|
||||
element<HTMLTextAreaElement>("#tokenJournalDetailOutput").value = "Aucun événement sélectionné.";
|
||||
return;
|
||||
}
|
||||
for (const item of rows) {
|
||||
const row = document.createElement("tr");
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
const values = [
|
||||
item.slot,
|
||||
item.operation ?? "—",
|
||||
item.family,
|
||||
item.amountRaw ?? "—",
|
||||
item.signature.length > 20 ? `${item.signature.slice(0, 10)}…${item.signature.slice(-8)}` : item.signature,
|
||||
];
|
||||
for (const value of values) {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = value;
|
||||
row.append(cell);
|
||||
}
|
||||
row.title = item.signature;
|
||||
const select = (): void => {
|
||||
element<HTMLTextAreaElement>("#tokenJournalDetailOutput").value = item.payloadJson;
|
||||
};
|
||||
row.addEventListener("click", select);
|
||||
row.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
select();
|
||||
}
|
||||
});
|
||||
body.append(row);
|
||||
}
|
||||
element<HTMLTextAreaElement>("#tokenJournalDetailOutput").value = rows[0].payloadJson;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function token2022ScenarioAmount(fixture: DemoSplToken2022FixturePayload, scenarioId: string): string {
|
||||
if (scenarioId === "token_2022_mint_to_checked") {
|
||||
return "1";
|
||||
}
|
||||
if (scenarioId === "token_2022_approve_checked") {
|
||||
return "2";
|
||||
}
|
||||
if (scenarioId === "token_2022_burn_checked") {
|
||||
return "1";
|
||||
}
|
||||
return fixture.defaultAmountRaw;
|
||||
}
|
||||
|
||||
function applyToken2022Fixture(): void {
|
||||
if (!token2022Fixture) {
|
||||
return;
|
||||
}
|
||||
const scenarioId = element<HTMLSelectElement>("#token2022ScenarioSelect").value;
|
||||
element<HTMLInputElement>("#token2022SourceInput").value = scenarioId === "token_2022_close_destination"
|
||||
? token2022Fixture.closeAccount
|
||||
: token2022Fixture.source;
|
||||
element<HTMLInputElement>("#token2022MintInput").value = token2022Fixture.mint;
|
||||
element<HTMLInputElement>("#token2022DestinationInput").value = scenarioId === "token_2022_close_destination"
|
||||
? token2022Fixture.authority
|
||||
: token2022Fixture.destination;
|
||||
element<HTMLInputElement>("#token2022DelegateInput").value = token2022Fixture.delegate;
|
||||
element<HTMLInputElement>("#token2022AuthorityInput").value = token2022Fixture.authority;
|
||||
element<HTMLInputElement>("#token2022FreezeAuthorityInput").value = token2022Fixture.freezeAuthority;
|
||||
element<HTMLInputElement>("#token2022AmountInput").value = token2022ScenarioAmount(token2022Fixture, scenarioId);
|
||||
element<HTMLInputElement>("#token2022DecimalsInput").value = String(token2022Fixture.decimals);
|
||||
}
|
||||
|
||||
async function loadToken2022Fixture(): Promise<void> {
|
||||
try {
|
||||
token2022Fixture = await invoke<DemoSplToken2022FixturePayload>("demo_spl_token_2022_fixture", {
|
||||
profileName: element<HTMLSelectElement>("#executionProfileSelect").value,
|
||||
});
|
||||
applyToken2022Fixture();
|
||||
element<HTMLElement>("#token2022FixtureHelp").textContent = `Fixture: ${token2022Fixture.fixturePath} — program=${token2022Fixture.programId}`;
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
stage: "token_2022_fixture",
|
||||
message: `fixture chargée: mint=${token2022Fixture.mint}, source=${token2022Fixture.source}, destination=${token2022Fixture.destination}`,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
token2022Fixture = null;
|
||||
element<HTMLElement>("#token2022FixtureHelp").textContent = message;
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", stage: "token_2022_fixture", message });
|
||||
}
|
||||
}
|
||||
|
||||
function buildToken2022Request(submit: boolean): DemoExecutionSplToken2022Request {
|
||||
const scenarioId = element<HTMLSelectElement>("#token2022ScenarioSelect").value;
|
||||
const source = element<HTMLInputElement>("#token2022SourceInput").value.trim();
|
||||
const mint = element<HTMLInputElement>("#token2022MintInput").value.trim();
|
||||
const destination = element<HTMLInputElement>("#token2022DestinationInput").value.trim();
|
||||
const delegate = element<HTMLInputElement>("#token2022DelegateInput").value.trim();
|
||||
const authority = element<HTMLInputElement>("#token2022AuthorityInput").value.trim();
|
||||
const freezeAuthority = element<HTMLInputElement>("#token2022FreezeAuthorityInput").value.trim();
|
||||
if (source.length === 0) {
|
||||
throw new Error("Le compte source ou cible Token-2022 est obligatoire.");
|
||||
}
|
||||
if (scenarioId !== "token_2022_revoke" && scenarioId !== "token_2022_close_destination" && mint.length === 0) {
|
||||
throw new Error("Le mint Token-2022 est obligatoire pour ce scénario.");
|
||||
}
|
||||
if (scenarioId === "token_2022_transfer_checked" && destination.length === 0) {
|
||||
throw new Error("Le compte destination Token-2022 est obligatoire.");
|
||||
}
|
||||
if (scenarioId === "token_2022_close_destination" && destination.length === 0) {
|
||||
throw new Error("La destination lamports est obligatoire pour CloseAccount.");
|
||||
}
|
||||
if (scenarioId === "token_2022_approve_checked" && delegate.length === 0) {
|
||||
throw new Error("Le delegate est obligatoire pour ApproveChecked.");
|
||||
}
|
||||
if (["token_2022_freeze_account", "token_2022_thaw_account"].includes(scenarioId) && freezeAuthority.length === 0) {
|
||||
throw new Error("La freeze authority est obligatoire pour FreezeAccount et ThawAccount.");
|
||||
}
|
||||
if (!["token_2022_freeze_account", "token_2022_thaw_account"].includes(scenarioId) && authority.length === 0) {
|
||||
throw new Error("L’autorité Token-2022 est obligatoire.");
|
||||
}
|
||||
return {
|
||||
profileName: element<HTMLSelectElement>("#executionProfileSelect").value,
|
||||
scenarioId,
|
||||
source,
|
||||
mint,
|
||||
destination,
|
||||
delegate,
|
||||
authority,
|
||||
freezeAuthority,
|
||||
amountRaw: element<HTMLInputElement>("#token2022AmountInput").value.trim(),
|
||||
decimals: integerValue("#token2022DecimalsInput"),
|
||||
submit,
|
||||
operatorConfirmed: element<HTMLInputElement>("#executionOperatorConfirmedInput").checked,
|
||||
forcePostValidationReplay: element<HTMLInputElement>("#executionForceReplayInput").checked,
|
||||
};
|
||||
}
|
||||
|
||||
function displayToken2022Summary(summary: DemoExecutionSplToken2022SummaryPayload): void {
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = JSON.stringify({
|
||||
scenarioId: summary.scenarioId,
|
||||
operation: summary.operation,
|
||||
profileName: summary.profileName,
|
||||
cluster: summary.cluster,
|
||||
genesisHash: summary.genesisHash,
|
||||
walletPublicKey: summary.walletPublicKey,
|
||||
balanceLamports: summary.balanceLamports,
|
||||
feeLamports: summary.feeLamports,
|
||||
simulationSuccess: summary.simulationSuccess,
|
||||
simulationError: summary.simulationError,
|
||||
transactionSignature: summary.transactionSignature,
|
||||
confirmationStatus: summary.confirmationStatus,
|
||||
canonicalInserted: summary.canonicalInserted,
|
||||
coreExtracted: summary.coreExtracted,
|
||||
decodeReplayed: summary.decodeReplayed,
|
||||
materializationCount: summary.materializationCount,
|
||||
idempotenceValidated: summary.idempotenceValidated,
|
||||
}, null, 2);
|
||||
element<HTMLTextAreaElement>("#executionPlanOutput").value = `PRÉFLIGHT\n${summary.preflightJson}\n\nPLAN\n${summary.planJson}`;
|
||||
element<HTMLTextAreaElement>("#executionSimulationOutput").value = summary.simulationJson;
|
||||
element<HTMLTextAreaElement>("#executionDiagnosticsOutput").value = summary.diagnosticsJson;
|
||||
}
|
||||
|
||||
async function executeToken2022(submit: boolean): Promise<void> {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
let request: DemoExecutionSplToken2022Request;
|
||||
try {
|
||||
request = buildToken2022Request(submit);
|
||||
if (submit && !request.operatorConfirmed) {
|
||||
const message = "La confirmation opérateur est obligatoire avant signature Token-2022.";
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = JSON.stringify({ warning: message }, null, 2);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", stage: "token_2022_failed", message });
|
||||
return;
|
||||
}
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", stage: "token_2022_failed", message });
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = "Préflight et exécution Token-2022 en cours...";
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
stage: submit ? "token_2022_submit" : "token_2022_simulation",
|
||||
message: `${request.scenarioId}, source=${request.source}`,
|
||||
});
|
||||
try {
|
||||
const summary = await invoke<DemoExecutionSplToken2022SummaryPayload>("demo_execution_spl_token_2022_execute", { request });
|
||||
displayToken2022Summary(summary);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: summary.simulationSuccess ? "info" : "error",
|
||||
stage: "token_2022_completed",
|
||||
message: `scenario=${summary.scenarioId}, simulation=${summary.simulationSuccess}, confirmation=${summary.confirmationStatus ?? "none"}, materializations=${summary.materializationCount}, idempotence=${summary.idempotenceValidated}`,
|
||||
signature: summary.transactionSignature,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLTextAreaElement>("#executionSummaryOutput").value = JSON.stringify({ error: message }, null, 2);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", stage: "token_2022_failed", message });
|
||||
frontendError("kb_app_demo.frontend.demo_execution_spl", `Token-2022 execution failed: ${message}`);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTokenJournal(): Promise<void> {
|
||||
const button = element<HTMLButtonElement>("#refreshTokenJournalButton");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const rows = await invoke<DemoSplTokenJournalRow[]>("demo_spl_token_journal", { request: tokenJournalRequest() });
|
||||
renderTokenJournal(rows);
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
stage: "spl_token_journal",
|
||||
message: `${rows.length} événement(s) matérialisé(s) chargé(s).`,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLTextAreaElement>("#tokenJournalDetailOutput").value = JSON.stringify({ error: message }, null, 2);
|
||||
frontendError("kb_app_demo.frontend.demo_execution_spl", `Token journal loading failed: ${message}`);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateRecipient(): Promise<void> {
|
||||
try {
|
||||
const payload = await invoke<DemoExecutionSolanaCoreGeneratedRecipientPayload>("demo_execution_solana_core_generate_recipient");
|
||||
element<HTMLInputElement>("#executionRecipientInput").value = payload.publicKey;
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
stage: "recipient",
|
||||
message: `Adresse destinataire jetable générée : ${payload.publicKey}`,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.demo_execution_spl", `Recipient generation failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelExecution(): Promise<void> {
|
||||
try {
|
||||
const accepted = await invoke<boolean>("demo_execution_solana_core_cancel");
|
||||
appendLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: accepted ? "warn" : "info",
|
||||
stage: "cancel",
|
||||
message: accepted ? "Demande d’annulation enregistrée." : "Aucune exécution active.",
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.demo_execution_spl", `Cancellation failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions(): Promise<void> {
|
||||
const options = await invoke<DemoExecutionSolanaCoreOptionsPayload>("demo_execution_solana_core_options");
|
||||
profileOptions = options.profiles;
|
||||
const select = element<HTMLSelectElement>("#executionProfileSelect");
|
||||
select.replaceChildren();
|
||||
for (const profile of profileOptions) {
|
||||
const option = document.createElement("option");
|
||||
option.value = profile.name;
|
||||
option.textContent = profile.name;
|
||||
option.selected = profile.name === options.defaultProfileName;
|
||||
select.append(option);
|
||||
}
|
||||
element<HTMLInputElement>("#executionLamportsInput").value = String(options.defaultTransferLamports);
|
||||
updateProfileHelp();
|
||||
setRunning(options.running);
|
||||
if (profileOptions.length === 0) {
|
||||
element<HTMLButtonElement>("#simulateExecutionButton").disabled = true;
|
||||
element<HTMLButtonElement>("#submitExecutionButton").disabled = true;
|
||||
element<HTMLButtonElement>("#simulateMemoButton").disabled = true;
|
||||
element<HTMLButtonElement>("#submitMemoButton").disabled = true;
|
||||
element<HTMLButtonElement>("#simulateTokenButton").disabled = true;
|
||||
element<HTMLButtonElement>("#submitTokenButton").disabled = true;
|
||||
element<HTMLButtonElement>("#deriveAtaButton").disabled = true;
|
||||
element<HTMLButtonElement>("#simulateAtaButton").disabled = true;
|
||||
element<HTMLButtonElement>("#submitAtaButton").disabled = true;
|
||||
element<HTMLButtonElement>("#simulateToken2022Button").disabled = true;
|
||||
element<HTMLButtonElement>("#submitToken2022Button").disabled = true;
|
||||
element<HTMLButtonElement>("#loadToken2022FixtureButton").disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
installFrontendConsoleBridge("kb_app_demo.frontend.demo_execution_spl");
|
||||
frontendDebug("kb_app_demo.frontend.demo_execution_spl", "execution demo window loaded");
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(item => new bootstrap.Tooltip(item));
|
||||
element<HTMLSelectElement>("#executionProfileSelect").addEventListener("change", updateProfileHelp);
|
||||
element<HTMLButtonElement>("#generateRecipientButton").addEventListener("click", () => {
|
||||
void generateRecipient();
|
||||
});
|
||||
element<HTMLButtonElement>("#simulateExecutionButton").addEventListener("click", () => {
|
||||
void execute(false);
|
||||
});
|
||||
element<HTMLButtonElement>("#submitExecutionButton").addEventListener("click", () => {
|
||||
void execute(true);
|
||||
});
|
||||
element<HTMLButtonElement>("#simulateMemoButton").addEventListener("click", () => {
|
||||
void executeMemo(false);
|
||||
});
|
||||
element<HTMLButtonElement>("#submitMemoButton").addEventListener("click", () => {
|
||||
void executeMemo(true);
|
||||
});
|
||||
element<HTMLButtonElement>("#simulateTokenButton").addEventListener("click", () => {
|
||||
void executeToken(false);
|
||||
});
|
||||
element<HTMLButtonElement>("#submitTokenButton").addEventListener("click", () => {
|
||||
void executeToken(true);
|
||||
});
|
||||
element<HTMLButtonElement>("#loadToken2022FixtureButton").addEventListener("click", () => {
|
||||
void loadToken2022Fixture();
|
||||
});
|
||||
element<HTMLSelectElement>("#token2022ScenarioSelect").addEventListener("change", applyToken2022Fixture);
|
||||
element<HTMLButtonElement>("#simulateToken2022Button").addEventListener("click", () => {
|
||||
void executeToken2022(false);
|
||||
});
|
||||
element<HTMLButtonElement>("#submitToken2022Button").addEventListener("click", () => {
|
||||
void executeToken2022(true);
|
||||
});
|
||||
element<HTMLButtonElement>("#refreshTokenJournalButton").addEventListener("click", () => {
|
||||
void loadTokenJournal();
|
||||
});
|
||||
element<HTMLButtonElement>("#deriveAtaButton").addEventListener("click", () => {
|
||||
void deriveAta();
|
||||
});
|
||||
element<HTMLButtonElement>("#simulateAtaButton").addEventListener("click", () => {
|
||||
void executeAta(false);
|
||||
});
|
||||
element<HTMLButtonElement>("#submitAtaButton").addEventListener("click", () => {
|
||||
void executeAta(true);
|
||||
});
|
||||
element<HTMLButtonElement>("#refreshAtaJournalButton").addEventListener("click", () => {
|
||||
void loadAtaJournal();
|
||||
});
|
||||
element<HTMLButtonElement>("#cancelExecutionButton").addEventListener("click", () => {
|
||||
void cancelExecution();
|
||||
});
|
||||
element<HTMLButtonElement>("#clearExecutionLogButton").addEventListener("click", () => {
|
||||
logLines.length = 0;
|
||||
element<HTMLTextAreaElement>("#executionLogOutput").value = "";
|
||||
});
|
||||
void listen<DemoExecutionSolanaCoreProgressPayload>("demo-execution-solana-core-progress", event => {
|
||||
appendLog(event.payload);
|
||||
});
|
||||
void loadOptions().catch(caughtError => {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
appendLog({ timestamp: new Date().toISOString(), level: "error", stage: "options", message });
|
||||
frontendError("kb_app_demo.frontend.demo_execution_spl", `Options loading failed: ${message}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// file: kb_app_demo/frontend/ts/demo_http.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 type { DemoHttpExecutionPayload } from "./bindings/kb_app_demo/demo_http/DemoHttpExecutionPayload";
|
||||
import type { DemoHttpMethodOption } from "./bindings/kb_app_demo/demo_http/DemoHttpMethodOption";
|
||||
import type { DemoHttpOptionsPayload } from "./bindings/kb_app_demo/demo_http/DemoHttpOptionsPayload";
|
||||
import type { DemoHttpRequest } from "./bindings/kb_app_demo/demo_http/DemoHttpRequest";
|
||||
import type { DemoHttpRoleOption } from "./bindings/kb_app_demo/demo_http/DemoHttpRoleOption";
|
||||
|
||||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
|
||||
let roleOptions: DemoHttpRoleOption[] = [];
|
||||
let methodOptions: DemoHttpMethodOption[] = [];
|
||||
|
||||
function textInputValue(selector: string): string {
|
||||
const element = document.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(selector);
|
||||
return element ? element.value.trim() : "";
|
||||
}
|
||||
|
||||
function writeTextarea(selector: string, value: string): void {
|
||||
const element = document.querySelector<HTMLTextAreaElement>(selector);
|
||||
if (element) {
|
||||
element.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonText(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function roleSupportsMethod(role: DemoHttpRoleOption, method: DemoHttpMethodOption): boolean {
|
||||
return role.requestKinds.includes("*") || role.requestKinds.includes(method.requestKind);
|
||||
}
|
||||
|
||||
function selectedRole(): DemoHttpRoleOption | null {
|
||||
const role = textInputValue("#httpRoleSelect");
|
||||
return roleOptions.find(option => option.role === role) ?? null;
|
||||
}
|
||||
|
||||
function populateSelect(selector: string, values: Array<{ value: string; label: string }>): void {
|
||||
const select = document.querySelector<HTMLSelectElement>(selector);
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
select.textContent = "";
|
||||
for (const item of values) {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.value;
|
||||
option.textContent = item.label;
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshMethodList(): void {
|
||||
const role = selectedRole();
|
||||
const filtered = role ? methodOptions.filter(method => roleSupportsMethod(role, method)) : methodOptions;
|
||||
populateSelect("#httpMethodSelect", filtered.map(method => ({
|
||||
value: method.method,
|
||||
label: `${method.method} — ${method.label}`,
|
||||
})));
|
||||
refreshMethodFields();
|
||||
}
|
||||
|
||||
function refreshMethodFields(): void {
|
||||
const methodName = textInputValue("#httpMethodSelect");
|
||||
const method = methodOptions.find(option => option.method === methodName) ?? null;
|
||||
const firstArg = document.querySelector<HTMLInputElement>("#httpFirstArgInput");
|
||||
const config = document.querySelector<HTMLTextAreaElement>("#httpConfigInput");
|
||||
const params = document.querySelector<HTMLTextAreaElement>("#httpParamsInput");
|
||||
if (firstArg) {
|
||||
firstArg.disabled = method ? !method.requiresFirstArg : false;
|
||||
firstArg.placeholder = method && method.requiresFirstArg ? "Argument requis" : "Non requis pour cette méthode";
|
||||
}
|
||||
if (config) {
|
||||
config.disabled = method ? !method.supportsConfigJson : false;
|
||||
}
|
||||
if (params) {
|
||||
params.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatHttpExecutionPayload(payload: DemoHttpExecutionPayload): string {
|
||||
const header = {
|
||||
endpointName: payload.endpointName,
|
||||
provider: payload.provider,
|
||||
endpointUrl: payload.endpointUrl,
|
||||
role: payload.role,
|
||||
method: payload.method,
|
||||
requestKind: payload.requestKind,
|
||||
methodClass: payload.methodClass,
|
||||
};
|
||||
return `${JSON.stringify(header, null, 2)}\n\n--- response ---\n${payload.responseJson}`;
|
||||
}
|
||||
|
||||
async function copyTextarea(selector: string): Promise<void> {
|
||||
const element = document.querySelector<HTMLTextAreaElement>(selector);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(element.value);
|
||||
}
|
||||
|
||||
async function refreshHttpOptions(): Promise<void> {
|
||||
try {
|
||||
const options = await invoke<DemoHttpOptionsPayload>("demo_http_options");
|
||||
roleOptions = options.roles;
|
||||
methodOptions = options.methods;
|
||||
populateSelect("#httpRoleSelect", roleOptions.map(role => ({
|
||||
value: role.role,
|
||||
label: role.role,
|
||||
})));
|
||||
refreshMethodList();
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
writeTextarea("#httpResultOutput", `Erreur options HTTP : ${message}`);
|
||||
frontendError("kb_app_demo.frontend.demo_http", `HTTP options loading failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHttpPool(): Promise<void> {
|
||||
try {
|
||||
const snapshots = await invoke("demo_http_list_pool_clients");
|
||||
writeTextarea("#httpPoolOutput", jsonText(snapshots));
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
writeTextarea("#httpPoolOutput", `Erreur : ${message}`);
|
||||
frontendError("kb_app_demo.frontend.demo_http", `HTTP pool refresh failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeHttpRequest(): Promise<void> {
|
||||
const request: DemoHttpRequest = {
|
||||
role: textInputValue("#httpRoleSelect"),
|
||||
method: textInputValue("#httpMethodSelect"),
|
||||
firstArg: textInputValue("#httpFirstArgInput") || null,
|
||||
configJson: textInputValue("#httpConfigInput") || null,
|
||||
paramsJson: textInputValue("#httpParamsInput") || null,
|
||||
};
|
||||
writeTextarea("#httpResultOutput", "Exécution en cours...");
|
||||
try {
|
||||
const response = await invoke<DemoHttpExecutionPayload>("demo_http_execute_request", { request });
|
||||
writeTextarea("#httpResultOutput", formatHttpExecutionPayload(response));
|
||||
frontendDebug("kb_app_demo.frontend.demo_http", `HTTP request completed: ${response.method}`);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
writeTextarea("#httpResultOutput", `Erreur : ${message}`);
|
||||
frontendError("kb_app_demo.frontend.demo_http", `HTTP request failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
installFrontendConsoleBridge("kb_app_demo.frontend.demo_http");
|
||||
frontendDebug("kb_app_demo.frontend.demo_http", "HTTP demo window loaded");
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
||||
document.querySelector<HTMLSelectElement>("#httpRoleSelect")?.addEventListener("change", refreshMethodList);
|
||||
document.querySelector<HTMLSelectElement>("#httpMethodSelect")?.addEventListener("change", refreshMethodFields);
|
||||
document.querySelector<HTMLButtonElement>("#executeHttpButton")?.addEventListener("click", () => {
|
||||
void executeHttpRequest();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#refreshHttpPoolButton")?.addEventListener("click", () => {
|
||||
void refreshHttpPool();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#copyHttpPoolButton")?.addEventListener("click", () => {
|
||||
void copyTextarea("#httpPoolOutput");
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#copyHttpResultButton")?.addEventListener("click", () => {
|
||||
void copyTextarea("#httpResultOutput");
|
||||
});
|
||||
void refreshHttpOptions();
|
||||
void refreshHttpPool();
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// file: kb_app_demo/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/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<void> {
|
||||
setText("#sqlDiagStatus", "Chargement...");
|
||||
try {
|
||||
const payload = await invoke<DemoSqlDiagPayload>("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<void> {
|
||||
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<HTMLButtonElement>("#refreshSqlDiagButton")?.addEventListener("click", () => {
|
||||
void refreshDiagnostics();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#copySqlDiagButton")?.addEventListener("click", () => {
|
||||
void copyDiagnostics();
|
||||
});
|
||||
void refreshDiagnostics();
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// file: kb_app_demo/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/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<void> {
|
||||
setText("#sqlCoreStatus", "Chargement...");
|
||||
try {
|
||||
const payload = await invoke<DemoSqlPgCorePayload>("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<HTMLButtonElement>("#refreshSqlCoreButton")?.addEventListener("click", () => {
|
||||
void refreshCore();
|
||||
});
|
||||
void refreshCore();
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// file: kb_app_demo/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/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<void> {
|
||||
setText("#sqlRawStatus", "Chargement...");
|
||||
try {
|
||||
const payload = await invoke<DemoSqlPgRawPayload>("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<HTMLButtonElement>("#refreshSqlRawButton")?.addEventListener("click", () => {
|
||||
void refreshRaw();
|
||||
});
|
||||
void refreshRaw();
|
||||
});
|
||||
@@ -0,0 +1,800 @@
|
||||
// file: kb_app_demo/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/demo_sql_replay_candidates/DemoSqlReplayEntityRequest";
|
||||
import type { DemoSqlReplayEntityRow } from "./bindings/kb_app_demo/demo_sql_replay_candidates/DemoSqlReplayEntityRow";
|
||||
import type { DemoSqlReplayKnownProgramOption } from "./bindings/kb_app_demo/demo_sql_replay_candidates/DemoSqlReplayKnownProgramOption";
|
||||
import type { DemoSqlReplayOptionsPayload } from "./bindings/kb_app_demo/demo_sql_replay_candidates/DemoSqlReplayOptionsPayload";
|
||||
import type { DemoSqlReplayProgramRequest } from "./bindings/kb_app_demo/demo_sql_replay_candidates/DemoSqlReplayProgramRequest";
|
||||
import type { DemoSqlReplayProgramRow } from "./bindings/kb_app_demo/demo_sql_replay_candidates/DemoSqlReplayProgramRow";
|
||||
import type { DemoSqlReplayTransactionRequest } from "./bindings/kb_app_demo/demo_sql_replay_candidates/DemoSqlReplayTransactionRequest";
|
||||
import type { DemoSqlReplayTransactionRow } from "./bindings/kb_app_demo/demo_sql_replay_candidates/DemoSqlReplayTransactionRow";
|
||||
|
||||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
|
||||
type EntityKindCode = "mint" | "owner" | "account_key";
|
||||
|
||||
type EntityViewConfig = {
|
||||
kind: EntityKindCode;
|
||||
label: string;
|
||||
pluralLabel: string;
|
||||
tableSelector: string;
|
||||
containsSelector: string;
|
||||
limitSelector: string;
|
||||
summarySelector: string;
|
||||
loadButtonSelector: string;
|
||||
resetButtonSelector: string;
|
||||
useButtonSelector: string;
|
||||
copyButtonSelector: string;
|
||||
exportButtonSelector: string;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
const tracingTarget = "kb_app_demo.frontend.demo_sql_replay_candidates";
|
||||
const entityViewConfigs: EntityViewConfig[] = [
|
||||
{
|
||||
kind: "mint",
|
||||
label: "mint",
|
||||
pluralLabel: "mints",
|
||||
tableSelector: "#mintCandidateTable",
|
||||
containsSelector: "#mintContainsInput",
|
||||
limitSelector: "#mintLimitInput",
|
||||
summarySelector: "#mintResultSummary",
|
||||
loadButtonSelector: "#loadMintsButton",
|
||||
resetButtonSelector: "#resetMintsButton",
|
||||
useButtonSelector: "#useSelectedMintsButton",
|
||||
copyButtonSelector: "#copyMintsButton",
|
||||
exportButtonSelector: "#exportMintsCsvButton",
|
||||
fileName: "replay_mints.csv",
|
||||
},
|
||||
{
|
||||
kind: "owner",
|
||||
label: "owner",
|
||||
pluralLabel: "owners",
|
||||
tableSelector: "#ownerCandidateTable",
|
||||
containsSelector: "#ownerContainsInput",
|
||||
limitSelector: "#ownerLimitInput",
|
||||
summarySelector: "#ownerResultSummary",
|
||||
loadButtonSelector: "#loadOwnersButton",
|
||||
resetButtonSelector: "#resetOwnersButton",
|
||||
useButtonSelector: "#useSelectedOwnersButton",
|
||||
copyButtonSelector: "#copyOwnersButton",
|
||||
exportButtonSelector: "#exportOwnersCsvButton",
|
||||
fileName: "replay_owners.csv",
|
||||
},
|
||||
{
|
||||
kind: "account_key",
|
||||
label: "compte",
|
||||
pluralLabel: "comptes",
|
||||
tableSelector: "#accountCandidateTable",
|
||||
containsSelector: "#accountContainsInput",
|
||||
limitSelector: "#accountLimitInput",
|
||||
summarySelector: "#accountResultSummary",
|
||||
loadButtonSelector: "#loadAccountsButton",
|
||||
resetButtonSelector: "#resetAccountsButton",
|
||||
useButtonSelector: "#useSelectedAccountsButton",
|
||||
copyButtonSelector: "#copyAccountsButton",
|
||||
exportButtonSelector: "#exportAccountsCsvButton",
|
||||
fileName: "replay_account_keys.csv",
|
||||
},
|
||||
];
|
||||
let transactionTable: Api<DemoSqlReplayTransactionRow> | null = null;
|
||||
let programTable: Api<DemoSqlReplayProgramRow> | null = null;
|
||||
let mintTable: Api<DemoSqlReplayEntityRow> | null = null;
|
||||
let ownerTable: Api<DemoSqlReplayEntityRow> | null = null;
|
||||
let accountTable: Api<DemoSqlReplayEntityRow> | null = null;
|
||||
let busyOperationCount = 0;
|
||||
let maximumReplayCandidateLimit = 5_000;
|
||||
|
||||
function element<T extends HTMLElement>(selector: string): T {
|
||||
const value = document.querySelector<T>(selector);
|
||||
if (!value) {
|
||||
throw new Error(`Missing UI element: ${selector}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalInputValue(selector: string): string | null {
|
||||
const value = element<HTMLInputElement | HTMLSelectElement>(selector).value.trim();
|
||||
return value.length === 0 ? null : value;
|
||||
}
|
||||
|
||||
function requiredInputValue(selector: string): string {
|
||||
const value = optionalInputValue(selector);
|
||||
if (value === null) {
|
||||
throw new Error(`Valeur obligatoire manquante pour ${selector}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveIntegerValue(selector: string): number {
|
||||
const value = Number.parseInt(requiredInputValue(selector), 10);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`Valeur entière positive invalide pour ${selector}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedPositiveIntegerValue(selector: string): number {
|
||||
const value = positiveIntegerValue(selector);
|
||||
if (value > maximumReplayCandidateLimit) {
|
||||
throw new Error(
|
||||
`La limite doit être comprise entre 1 et ${maximumReplayCandidateLimit} pour ${selector}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalNonNegativeIntegerValue(selector: string): number | null {
|
||||
const raw = optionalInputValue(selector);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error(`Valeur entière positive ou nulle invalide pour ${selector}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
const text = String(value ?? "");
|
||||
return text
|
||||
.split("&").join("&")
|
||||
.split("<").join("<")
|
||||
.split(">").join(">")
|
||||
.split('"').join(""")
|
||||
.split("'").join("'");
|
||||
}
|
||||
|
||||
function codeDisplay(value: unknown, type: string): string {
|
||||
if (type !== "display") {
|
||||
return String(value ?? "");
|
||||
}
|
||||
return `<code class="text-break">${escapeHtml(value)}</code>`;
|
||||
}
|
||||
|
||||
function compactIdentifier(value: string, leadingLength: number = 12, trailingLength: number = 10): string {
|
||||
if (value.length <= leadingLength + trailingLength + 1) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, leadingLength)}…${value.slice(-trailingLength)}`;
|
||||
}
|
||||
|
||||
function copyableCodeDisplay(label: string): (value: unknown, type: string) => string {
|
||||
return (value: unknown, type: string): string => {
|
||||
const text = String(value ?? "");
|
||||
if (type !== "display") {
|
||||
return text;
|
||||
}
|
||||
const escapedText = escapeHtml(text);
|
||||
const compactText = escapeHtml(compactIdentifier(text));
|
||||
const escapedLabel = escapeHtml(label);
|
||||
return `<span class="d-inline-flex align-items-center gap-1 text-nowrap"><code data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedText}">${compactText}</code><button class="btn btn-sm btn-link text-secondary p-0 replay-cell-copy" type="button" data-bs-toggle="tooltip" data-bs-placement="top" title="Copier ${escapedLabel}" aria-label="Copier ${escapedLabel}" data-copy-value="${escapedText}" data-copy-label="${escapedLabel}"><i class="fa-regular fa-copy" aria-hidden="true"></i></button></span>`;
|
||||
};
|
||||
}
|
||||
|
||||
function badgeDisplay(value: unknown, type: string): string {
|
||||
if (type !== "display") {
|
||||
return String(value ?? "");
|
||||
}
|
||||
const text = String(value ?? "—");
|
||||
const className = text === "succeeded" || text === "core_extracted"
|
||||
? "text-bg-success"
|
||||
: text === "failed"
|
||||
? "text-bg-danger"
|
||||
: text === "running" || text === "received"
|
||||
? "text-bg-warning"
|
||||
: "text-bg-secondary";
|
||||
return `<span class="badge ${className}">${escapeHtml(text)}</span>`;
|
||||
}
|
||||
|
||||
function coreStatusText(row: DemoSqlReplayTransactionRow): string {
|
||||
if (!row.hasCoreTransaction) {
|
||||
return "absent";
|
||||
}
|
||||
if (row.transactionFailed === true) {
|
||||
return "échec on-chain";
|
||||
}
|
||||
return "présent";
|
||||
}
|
||||
|
||||
function coreDisplay(_value: unknown, type: string, row: DemoSqlReplayTransactionRow): string | number {
|
||||
if (type === "display") {
|
||||
if (!row.hasCoreTransaction) {
|
||||
return '<span class="badge text-bg-secondary">absent</span>';
|
||||
}
|
||||
if (row.transactionFailed === true) {
|
||||
return '<span class="badge text-bg-danger" title="La transaction Solana a échoué on-chain, mais son graphe core a été extrait.">échec on-chain</span>';
|
||||
}
|
||||
return '<span class="badge text-bg-success">présent</span>';
|
||||
}
|
||||
if (type === "filter") {
|
||||
return coreStatusText(row);
|
||||
}
|
||||
if (row.transactionFailed === true) {
|
||||
return 0;
|
||||
}
|
||||
return row.hasCoreTransaction ? 1 : 2;
|
||||
}
|
||||
|
||||
function occurrenceRatioDisplay(_value: unknown, type: string, row: DemoSqlReplayEntityRow): string | number {
|
||||
const ratio = row.transactionCount > 0 ? row.occurrenceCount / row.transactionCount : 0;
|
||||
if (type === "display" || type === "filter") {
|
||||
return ratio.toFixed(2);
|
||||
}
|
||||
return ratio;
|
||||
}
|
||||
|
||||
function slotSpanDisplay(_value: unknown, type: string, row: DemoSqlReplayEntityRow): string | number {
|
||||
const span = Math.max(0, row.maxSlot - row.minSlot);
|
||||
if (type === "display" || type === "filter") {
|
||||
return String(span);
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
function commonLanguage(emptyTable: string, infoLabel: string, zeroRecords: string): object {
|
||||
return {
|
||||
emptyTable,
|
||||
info: `_START_ à _END_ sur _TOTAL_ ${infoLabel}`,
|
||||
infoEmpty: `0 ${infoLabel}`,
|
||||
infoFiltered: "(filtré depuis _MAX_)",
|
||||
lengthMenu: "Afficher _MENU_",
|
||||
search: "Filtre local :",
|
||||
zeroRecords,
|
||||
paginate: { first: "Premier", last: "Dernier", next: "Suivant", previous: "Précédent" },
|
||||
select: { rows: { _: "%d lignes sélectionnées", 0: "", 1: "1 ligne sélectionnée" } },
|
||||
};
|
||||
}
|
||||
|
||||
function createTransactionTable(): Api<DemoSqlReplayTransactionRow> {
|
||||
return new DataTable<DemoSqlReplayTransactionRow>("#transactionCandidateTable", {
|
||||
data: [],
|
||||
columns: [
|
||||
{
|
||||
data: null,
|
||||
className: "replay-select-column text-center",
|
||||
width: "1%",
|
||||
orderable: false,
|
||||
searchable: false,
|
||||
render: DataTable.render.select(),
|
||||
},
|
||||
{ data: "signature", render: copyableCodeDisplay("la signature") },
|
||||
{ data: "slot" },
|
||||
{ data: "rawProcessingState", render: badgeDisplay },
|
||||
{ data: "hasCoreTransaction", render: coreDisplay },
|
||||
{ data: "ledgerStatus", render: badgeDisplay },
|
||||
{ data: "processorVersion", defaultContent: "—", render: codeDisplay },
|
||||
{ data: "attemptCount" },
|
||||
{ data: "outerInstructionCount" },
|
||||
{ data: "innerInstructionCount" },
|
||||
{ data: "outerProgramCount" },
|
||||
{ data: "innerProgramCount" },
|
||||
{ data: "updatedAt" },
|
||||
],
|
||||
order: [[2, "desc"]],
|
||||
pageLength: 10,
|
||||
lengthMenu: [10, 25, 50, 100, 250],
|
||||
select: { style: "multi", selector: "td:first-child", headerCheckbox: "select-all" },
|
||||
language: commonLanguage(
|
||||
"Aucune transaction chargée",
|
||||
"transactions",
|
||||
"Aucune transaction ne correspond au filtre local",
|
||||
),
|
||||
drawCallback: () => initializeTooltips(),
|
||||
});
|
||||
}
|
||||
|
||||
function createProgramTable(): Api<DemoSqlReplayProgramRow> {
|
||||
return new DataTable<DemoSqlReplayProgramRow>("#programCandidateTable", {
|
||||
data: [],
|
||||
columns: [
|
||||
{
|
||||
data: null,
|
||||
className: "replay-select-column text-center",
|
||||
width: "1%",
|
||||
orderable: false,
|
||||
searchable: false,
|
||||
render: DataTable.render.select(),
|
||||
},
|
||||
{ data: "programCode", defaultContent: "—", render: codeDisplay },
|
||||
{ data: "programId", render: copyableCodeDisplay("le program ID") },
|
||||
{ data: "transactionCount" },
|
||||
{ data: "outerInstructionCount" },
|
||||
{ data: "innerInstructionCount" },
|
||||
{ data: "logCount" },
|
||||
{ data: "minSlot" },
|
||||
{ data: "maxSlot" },
|
||||
],
|
||||
order: [[3, "desc"]],
|
||||
pageLength: 10,
|
||||
lengthMenu: [10, 25, 50, 100, 250],
|
||||
select: { style: "multi", selector: "td:first-child", headerCheckbox: "select-all" },
|
||||
language: commonLanguage(
|
||||
"Aucun programme chargé",
|
||||
"programmes",
|
||||
"Aucun programme ne correspond au filtre local",
|
||||
),
|
||||
drawCallback: () => initializeTooltips(),
|
||||
});
|
||||
}
|
||||
|
||||
function createEntityTable(config: EntityViewConfig): Api<DemoSqlReplayEntityRow> {
|
||||
return new DataTable<DemoSqlReplayEntityRow>(config.tableSelector, {
|
||||
data: [],
|
||||
columns: [
|
||||
{
|
||||
data: null,
|
||||
className: "replay-select-column text-center",
|
||||
width: "1%",
|
||||
orderable: false,
|
||||
searchable: false,
|
||||
render: DataTable.render.select(),
|
||||
},
|
||||
{ data: "entityValue", render: copyableCodeDisplay(config.label) },
|
||||
{ data: "transactionCount" },
|
||||
{ data: "occurrenceCount" },
|
||||
{ data: null, render: occurrenceRatioDisplay },
|
||||
{ data: "minSlot" },
|
||||
{ data: "maxSlot" },
|
||||
{ data: null, render: slotSpanDisplay },
|
||||
],
|
||||
order: [[2, "desc"]],
|
||||
pageLength: 10,
|
||||
lengthMenu: [10, 25, 50, 100, 250],
|
||||
select: { style: "multi", selector: "td:first-child", headerCheckbox: "select-all" },
|
||||
language: commonLanguage(
|
||||
`Aucun ${config.label} chargé`,
|
||||
config.pluralLabel,
|
||||
`Aucun ${config.label} ne correspond au filtre local`,
|
||||
),
|
||||
drawCallback: () => initializeTooltips(),
|
||||
});
|
||||
}
|
||||
|
||||
function entityTable(kind: EntityKindCode): Api<DemoSqlReplayEntityRow> | null {
|
||||
if (kind === "mint") {
|
||||
return mintTable;
|
||||
}
|
||||
if (kind === "owner") {
|
||||
return ownerTable;
|
||||
}
|
||||
return accountTable;
|
||||
}
|
||||
|
||||
function replaceRows<T>(table: Api<T>, rows: T[]): void {
|
||||
table.clear();
|
||||
table.rows.add(rows);
|
||||
table.draw();
|
||||
}
|
||||
|
||||
function resetTableState<T>(table: Api<T> | null): void {
|
||||
if (table === null) {
|
||||
return;
|
||||
}
|
||||
table.search("");
|
||||
table.columns().search("");
|
||||
table.rows().deselect();
|
||||
table.draw();
|
||||
}
|
||||
|
||||
function selectedOrFilteredRows<T>(table: Api<T>): T[] {
|
||||
const selected = table.rows({ selected: true, search: "applied" }).data().toArray();
|
||||
if (selected.length > 0) {
|
||||
return selected;
|
||||
}
|
||||
return table.rows({ search: "applied" }).data().toArray();
|
||||
}
|
||||
|
||||
function selectedOrSingleFilteredRow<T>(table: Api<T>, label: string): T {
|
||||
const selected = table.rows({ selected: true }).data().toArray();
|
||||
if (selected.length === 1) {
|
||||
return selected[0];
|
||||
}
|
||||
if (selected.length > 1) {
|
||||
throw new Error(`Plusieurs ${label} sont cochés. Conservez une seule sélection.`);
|
||||
}
|
||||
const filtered = table.rows({ search: "applied" }).data().toArray();
|
||||
if (filtered.length === 1) {
|
||||
return filtered[0];
|
||||
}
|
||||
throw new Error(`Cochez exactement un ${label}, ou réduisez le filtre local à une seule ligne.`);
|
||||
}
|
||||
|
||||
function setBusy(busy: boolean, message: string): void {
|
||||
busyOperationCount = busy ? busyOperationCount + 1 : Math.max(0, busyOperationCount - 1);
|
||||
const operationInProgress = busyOperationCount > 0;
|
||||
const badge = element<HTMLElement>("#replayCandidateStatusBadge");
|
||||
badge.textContent = operationInProgress ? (busy ? message : "Chargement en cours") : "Prêt";
|
||||
badge.className = operationInProgress ? "badge text-bg-warning" : "badge text-bg-success";
|
||||
document.querySelectorAll<HTMLButtonElement>("button").forEach(button => {
|
||||
if (button.id !== "openCoreExtractionButton") {
|
||||
button.disabled = operationInProgress;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildTransactionRequest(): DemoSqlReplayTransactionRequest {
|
||||
const entityKind = optionalInputValue("#transactionEntityKindSelect");
|
||||
const entityValue = optionalInputValue("#transactionEntityValueInput");
|
||||
if ((entityKind === null) !== (entityValue === null)) {
|
||||
throw new Error("Le type d’entité et sa valeur doivent être renseignés ensemble.");
|
||||
}
|
||||
return {
|
||||
signatureContains: optionalInputValue("#transactionSignatureContainsInput"),
|
||||
minSlot: optionalNonNegativeIntegerValue("#transactionMinSlotInput"),
|
||||
maxSlot: optionalNonNegativeIntegerValue("#transactionMaxSlotInput"),
|
||||
rawProcessingState: optionalInputValue("#transactionRawStateSelect"),
|
||||
ledgerStatus: optionalInputValue("#transactionLedgerStatusSelect"),
|
||||
programId: optionalInputValue("#transactionProgramIdInput"),
|
||||
programScope: requiredInputValue("#transactionProgramScopeSelect"),
|
||||
entityKind,
|
||||
entityValue,
|
||||
limit: boundedPositiveIntegerValue("#transactionLimitInput"),
|
||||
newestFirst: element<HTMLInputElement>("#transactionNewestFirstInput").checked,
|
||||
};
|
||||
}
|
||||
|
||||
async function resetTransactionFilters(): Promise<void> {
|
||||
element<HTMLInputElement>("#transactionSignatureContainsInput").value = "";
|
||||
element<HTMLInputElement>("#transactionMinSlotInput").value = "";
|
||||
element<HTMLInputElement>("#transactionMaxSlotInput").value = "";
|
||||
element<HTMLSelectElement>("#transactionRawStateSelect").value = "";
|
||||
element<HTMLSelectElement>("#transactionLedgerStatusSelect").value = "";
|
||||
element<HTMLInputElement>("#transactionProgramIdInput").value = "";
|
||||
element<HTMLSelectElement>("#transactionProgramScopeSelect").value = "any";
|
||||
element<HTMLSelectElement>("#transactionEntityKindSelect").value = "";
|
||||
element<HTMLInputElement>("#transactionEntityValueInput").value = "";
|
||||
element<HTMLInputElement>("#transactionLimitInput").value = "500";
|
||||
element<HTMLInputElement>("#transactionNewestFirstInput").checked = true;
|
||||
resetTableState(transactionTable);
|
||||
await loadTransactions();
|
||||
}
|
||||
|
||||
async function resetProgramFilters(): Promise<void> {
|
||||
element<HTMLSelectElement>("#knownProgramSelect").value = "";
|
||||
element<HTMLInputElement>("#programContainsInput").value = "";
|
||||
element<HTMLInputElement>("#programLimitInput").value = "500";
|
||||
resetTableState(programTable);
|
||||
await loadPrograms();
|
||||
}
|
||||
|
||||
async function resetEntityFilters(config: EntityViewConfig): Promise<void> {
|
||||
element<HTMLInputElement>(config.containsSelector).value = "";
|
||||
element<HTMLInputElement>(config.limitSelector).value = "500";
|
||||
resetTableState(entityTable(config.kind));
|
||||
await loadEntity(config);
|
||||
}
|
||||
|
||||
async function loadTransactions(): Promise<void> {
|
||||
if (transactionTable === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setBusy(true, "Chargement transactions");
|
||||
const request = buildTransactionRequest();
|
||||
const rows = await invoke<DemoSqlReplayTransactionRow[]>("load_demo_sql_replay_transactions", { request });
|
||||
replaceRows(transactionTable, rows);
|
||||
element<HTMLElement>("#transactionResultSummary").textContent = `${rows.length} transaction(s) chargée(s) depuis PostgreSQL.`;
|
||||
frontendDebug(tracingTarget, `loaded ${rows.length} replay transaction candidates`);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLElement>("#transactionResultSummary").textContent = `Erreur : ${message}`;
|
||||
frontendError(tracingTarget, `transaction candidate loading failed: ${message}`);
|
||||
} finally {
|
||||
setBusy(false, "Prêt");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrograms(): Promise<void> {
|
||||
if (programTable === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setBusy(true, "Chargement programmes");
|
||||
const request: DemoSqlReplayProgramRequest = {
|
||||
programIdContains: optionalInputValue("#programContainsInput"),
|
||||
limit: boundedPositiveIntegerValue("#programLimitInput"),
|
||||
};
|
||||
const rows = await invoke<DemoSqlReplayProgramRow[]>("load_demo_sql_replay_programs", { request });
|
||||
replaceRows(programTable, rows);
|
||||
element<HTMLElement>("#programResultSummary").textContent = `${rows.length} programme(s) chargé(s), avec occurrences outer, inner et logs liés.`;
|
||||
frontendDebug(tracingTarget, `loaded ${rows.length} replay program summaries`);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLElement>("#programResultSummary").textContent = `Erreur : ${message}`;
|
||||
frontendError(tracingTarget, `program summary loading failed: ${message}`);
|
||||
} finally {
|
||||
setBusy(false, "Prêt");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntity(config: EntityViewConfig): Promise<void> {
|
||||
const table = entityTable(config.kind);
|
||||
if (table === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setBusy(true, `Chargement ${config.pluralLabel}`);
|
||||
const request: DemoSqlReplayEntityRequest = {
|
||||
entityKind: config.kind,
|
||||
entityValueContains: optionalInputValue(config.containsSelector),
|
||||
limit: boundedPositiveIntegerValue(config.limitSelector),
|
||||
};
|
||||
const rows = await invoke<DemoSqlReplayEntityRow[]>("load_demo_sql_replay_entities", { request });
|
||||
replaceRows(table, rows);
|
||||
element<HTMLElement>(config.summarySelector).textContent = `${rows.length} ${config.pluralLabel} chargé(s) depuis PostgreSQL.`;
|
||||
frontendDebug(tracingTarget, `loaded ${rows.length} replay ${config.kind} summaries`);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLElement>(config.summarySelector).textContent = `Erreur : ${message}`;
|
||||
frontendError(tracingTarget, `${config.kind} summary loading failed: ${message}`);
|
||||
} finally {
|
||||
setBusy(false, "Prêt");
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLines(lines: string[], label: string): Promise<void> {
|
||||
if (lines.length === 0) {
|
||||
throw new Error(`Aucune ${label} à copier.`);
|
||||
}
|
||||
await navigator.clipboard.writeText(lines.join("\n"));
|
||||
element<HTMLElement>("#replayCandidateStatusBadge").textContent = `${lines.length} ${label} copiée(s)`;
|
||||
}
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
const text = value === null || value === undefined ? "" : String(value);
|
||||
return `"${text.split('"').join('""')}"`;
|
||||
}
|
||||
|
||||
function csvContent(headers: string[], rows: unknown[][]): string {
|
||||
const lines = [headers, ...rows].map(row => row.map(csvCell).join(";"));
|
||||
return `${lines.join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
async function saveCsv(fileName: string, content: string, rowCount: number): Promise<void> {
|
||||
if (rowCount === 0) {
|
||||
throw new Error("Aucune ligne à exporter.");
|
||||
}
|
||||
const path = await invoke<string>("export_demo_sql_replay_csv", { fileName, content });
|
||||
const badge = element<HTMLElement>("#replayCandidateStatusBadge");
|
||||
badge.textContent = `${rowCount} ligne(s) exportée(s) : ${path}`;
|
||||
badge.className = "badge text-bg-success";
|
||||
frontendDebug(tracingTarget, `CSV export written: ${path}`);
|
||||
}
|
||||
|
||||
async function exportTransactionsCsv(): Promise<void> {
|
||||
const rows = transactionRowsForExport();
|
||||
const content = csvContent(
|
||||
["signature", "slot", "raw_processing_state", "retention_state", "core_status", "ledger_status", "processor_version", "attempt_count", "outer_instruction_count", "inner_instruction_count", "outer_program_count", "inner_program_count", "updated_at"],
|
||||
rows.map(row => [row.signature, row.slot, row.rawProcessingState, row.retentionState, coreStatusText(row), row.ledgerStatus, row.processorVersion, row.attemptCount, row.outerInstructionCount, row.innerInstructionCount, row.outerProgramCount, row.innerProgramCount, row.updatedAt]),
|
||||
);
|
||||
await saveCsv("replay_transactions.csv", content, rows.length);
|
||||
}
|
||||
|
||||
async function exportProgramsCsv(): Promise<void> {
|
||||
const rows = programRowsForExport();
|
||||
const content = csvContent(
|
||||
["program_code", "program_id", "transaction_count", "outer_instruction_count", "inner_instruction_count", "log_count", "min_slot", "max_slot"],
|
||||
rows.map(row => [row.programCode, row.programId, row.transactionCount, row.outerInstructionCount, row.innerInstructionCount, row.logCount, row.minSlot, row.maxSlot]),
|
||||
);
|
||||
await saveCsv("replay_programs.csv", content, rows.length);
|
||||
}
|
||||
|
||||
async function exportEntityCsv(config: EntityViewConfig): Promise<void> {
|
||||
const rows = entityRowsForExport(config.kind);
|
||||
const content = csvContent(
|
||||
["entity_kind", "entity_value", "transaction_count", "occurrence_count", "occurrences_per_transaction", "min_slot", "max_slot", "slot_span"],
|
||||
rows.map(row => [row.entityKind, row.entityValue, row.transactionCount, row.occurrenceCount, row.transactionCount > 0 ? row.occurrenceCount / row.transactionCount : 0, row.minSlot, row.maxSlot, Math.max(0, row.maxSlot - row.minSlot)]),
|
||||
);
|
||||
await saveCsv(config.fileName, content, rows.length);
|
||||
}
|
||||
|
||||
function transactionRowsForExport(): DemoSqlReplayTransactionRow[] {
|
||||
if (transactionTable === null) {
|
||||
return [];
|
||||
}
|
||||
return selectedOrFilteredRows(transactionTable);
|
||||
}
|
||||
|
||||
function programRowsForExport(): DemoSqlReplayProgramRow[] {
|
||||
if (programTable === null) {
|
||||
return [];
|
||||
}
|
||||
return selectedOrFilteredRows(programTable);
|
||||
}
|
||||
|
||||
function entityRowsForExport(kind: EntityKindCode): DemoSqlReplayEntityRow[] {
|
||||
const table = entityTable(kind);
|
||||
if (table === null) {
|
||||
return [];
|
||||
}
|
||||
return selectedOrFilteredRows(table);
|
||||
}
|
||||
|
||||
function showTransactionsTab(): void {
|
||||
bootstrap.Tab.getOrCreateInstance(element<HTMLButtonElement>("#transactionsTabButton")).show();
|
||||
}
|
||||
|
||||
async function useSelectedProgram(): Promise<void> {
|
||||
if (programTable === null) {
|
||||
return;
|
||||
}
|
||||
const row = selectedOrSingleFilteredRow(programTable, "programme");
|
||||
element<HTMLInputElement>("#transactionProgramIdInput").value = row.programId;
|
||||
element<HTMLSelectElement>("#transactionProgramScopeSelect").value = "any";
|
||||
showTransactionsTab();
|
||||
await loadTransactions();
|
||||
}
|
||||
|
||||
async function useSelectedEntity(config: EntityViewConfig): Promise<void> {
|
||||
const table = entityTable(config.kind);
|
||||
if (table === null) {
|
||||
return;
|
||||
}
|
||||
const row = selectedOrSingleFilteredRow(table, config.label);
|
||||
element<HTMLSelectElement>("#transactionEntityKindSelect").value = config.kind;
|
||||
element<HTMLInputElement>("#transactionEntityValueInput").value = row.entityValue;
|
||||
showTransactionsTab();
|
||||
await loadTransactions();
|
||||
}
|
||||
|
||||
async function openCoreExtraction(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_core_extraction_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError(tracingTarget, `core extraction window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions(): Promise<void> {
|
||||
const options = await invoke<DemoSqlReplayOptionsPayload>("demo_sql_replay_options");
|
||||
if (!Number.isSafeInteger(options.maximumLimit) || options.maximumLimit <= 0) {
|
||||
throw new Error("La limite maximale des candidats replay retournée par le backend est invalide.");
|
||||
}
|
||||
maximumReplayCandidateLimit = options.maximumLimit;
|
||||
element<HTMLElement>("#replayCandidateProfileBadge").textContent = `Profil ${options.activeProfileName}`;
|
||||
element<HTMLElement>("#replayCandidateDsn").textContent = options.maskedDsn;
|
||||
element<HTMLElement>("#replayCandidateMaximumLimit").textContent = String(options.maximumLimit);
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=number][max]").forEach(input => {
|
||||
input.max = String(options.maximumLimit);
|
||||
});
|
||||
populateKnownPrograms(options.knownPrograms);
|
||||
}
|
||||
|
||||
function populateKnownPrograms(programs: DemoSqlReplayKnownProgramOption[]): void {
|
||||
const select = element<HTMLSelectElement>("#knownProgramSelect");
|
||||
const datalist = element<HTMLDataListElement>("#knownProgramDatalist");
|
||||
select.replaceChildren(new Option("Tous les programmes connus", ""));
|
||||
datalist.replaceChildren();
|
||||
for (const program of programs) {
|
||||
const label = `${program.code} — ${program.programId}`;
|
||||
select.add(new Option(label, program.programId));
|
||||
const option = document.createElement("option");
|
||||
option.value = program.programId;
|
||||
option.label = program.code;
|
||||
datalist.append(option);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeTooltips(): void {
|
||||
document.querySelectorAll<HTMLElement>('[data-bs-toggle="tooltip"]').forEach(target => {
|
||||
bootstrap.Tooltip.getOrCreateInstance(target);
|
||||
});
|
||||
}
|
||||
|
||||
function adjustVisibleTables(): void {
|
||||
transactionTable?.columns.adjust();
|
||||
programTable?.columns.adjust();
|
||||
mintTable?.columns.adjust();
|
||||
ownerTable?.columns.adjust();
|
||||
accountTable?.columns.adjust();
|
||||
}
|
||||
|
||||
function installInlineCopyHandler(): void {
|
||||
document.addEventListener("click", event => {
|
||||
const source = event.target;
|
||||
if (!(source instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
const button = source.closest<HTMLButtonElement>("button[data-copy-value]");
|
||||
if (button === null) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const value = button.dataset.copyValue ?? "";
|
||||
const label = button.dataset.copyLabel ?? "valeur";
|
||||
void copyLines([value], label).catch(error => reportUiError(`copy ${label}`, error));
|
||||
});
|
||||
}
|
||||
|
||||
function reportUiError(action: string, caughtError: unknown): void {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
element<HTMLElement>("#replayCandidateStatusBadge").textContent = `Erreur : ${message}`;
|
||||
element<HTMLElement>("#replayCandidateStatusBadge").className = "badge text-bg-danger";
|
||||
frontendError(tracingTarget, `${action} failed: ${message}`);
|
||||
}
|
||||
|
||||
function installHandlers(): void {
|
||||
element<HTMLButtonElement>("#loadTransactionsButton").addEventListener("click", () => void loadTransactions());
|
||||
element<HTMLButtonElement>("#resetTransactionsButton").addEventListener("click", () => {
|
||||
void resetTransactionFilters().catch(error => reportUiError("reset transaction filters", error));
|
||||
});
|
||||
element<HTMLButtonElement>("#loadProgramsButton").addEventListener("click", () => void loadPrograms());
|
||||
element<HTMLButtonElement>("#resetProgramsButton").addEventListener("click", () => {
|
||||
void resetProgramFilters().catch(error => reportUiError("reset program filters", error));
|
||||
});
|
||||
element<HTMLButtonElement>("#openCoreExtractionButton").addEventListener("click", () => void openCoreExtraction());
|
||||
element<HTMLButtonElement>("#copySignaturesButton").addEventListener("click", () => {
|
||||
void copyLines(transactionRowsForExport().map(row => row.signature), "signature").catch(error => reportUiError("copy signatures", error));
|
||||
});
|
||||
element<HTMLButtonElement>("#exportTransactionsCsvButton").addEventListener("click", () => {
|
||||
void exportTransactionsCsv().catch(error => reportUiError("export transactions CSV", error));
|
||||
});
|
||||
element<HTMLButtonElement>("#copyProgramsButton").addEventListener("click", () => {
|
||||
void copyLines(programRowsForExport().map(row => row.programId), "program ID").catch(error => reportUiError("copy programs", error));
|
||||
});
|
||||
element<HTMLButtonElement>("#exportProgramsCsvButton").addEventListener("click", () => {
|
||||
void exportProgramsCsv().catch(error => reportUiError("export programs CSV", error));
|
||||
});
|
||||
element<HTMLButtonElement>("#useSelectedProgramButton").addEventListener("click", () => {
|
||||
void useSelectedProgram().catch(error => reportUiError("use selected program", error));
|
||||
});
|
||||
for (const config of entityViewConfigs) {
|
||||
element<HTMLButtonElement>(config.loadButtonSelector).addEventListener("click", () => void loadEntity(config));
|
||||
element<HTMLButtonElement>(config.resetButtonSelector).addEventListener("click", () => {
|
||||
void resetEntityFilters(config).catch(error => reportUiError(`reset ${config.pluralLabel} filters`, error));
|
||||
});
|
||||
element<HTMLButtonElement>(config.copyButtonSelector).addEventListener("click", () => {
|
||||
void copyLines(entityRowsForExport(config.kind).map(row => row.entityValue), config.label).catch(error => reportUiError(`copy ${config.pluralLabel}`, error));
|
||||
});
|
||||
element<HTMLButtonElement>(config.exportButtonSelector).addEventListener("click", () => {
|
||||
void exportEntityCsv(config).catch(error => reportUiError(`export ${config.pluralLabel} CSV`, error));
|
||||
});
|
||||
element<HTMLButtonElement>(config.useButtonSelector).addEventListener("click", () => {
|
||||
void useSelectedEntity(config).catch(error => reportUiError(`use selected ${config.label}`, error));
|
||||
});
|
||||
}
|
||||
element<HTMLSelectElement>("#knownProgramSelect").addEventListener("change", event => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
element<HTMLInputElement>("#programContainsInput").value = target.value;
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-bs-toggle="tab"]').forEach(button => {
|
||||
button.addEventListener("shown.bs.tab", adjustVisibleTables);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
installFrontendConsoleBridge(tracingTarget);
|
||||
frontendDebug(tracingTarget, "SQL replay candidate browser loaded");
|
||||
transactionTable = createTransactionTable();
|
||||
programTable = createProgramTable();
|
||||
mintTable = createEntityTable(entityViewConfigs[0]);
|
||||
ownerTable = createEntityTable(entityViewConfigs[1]);
|
||||
accountTable = createEntityTable(entityViewConfigs[2]);
|
||||
initializeTooltips();
|
||||
installHandlers();
|
||||
installInlineCopyHandler();
|
||||
void loadOptions()
|
||||
.then(() => Promise.all([
|
||||
loadTransactions(),
|
||||
loadPrograms(),
|
||||
loadEntity(entityViewConfigs[0]),
|
||||
loadEntity(entityViewConfigs[1]),
|
||||
loadEntity(entityViewConfigs[2]),
|
||||
]))
|
||||
.catch(caughtError => reportUiError("initial loading", caughtError));
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// file: kb_app_demo/frontend/ts/demo_sql_tables.ts
|
||||
// version: 2
|
||||
|
||||
import "@andypf/json-viewer";
|
||||
import type { DemoSqlTableSnapshot } from "./bindings/kb_app_demo/demo_sql/DemoSqlTableSnapshot";
|
||||
|
||||
export function setText(selector: string, value: string): void {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (element) {
|
||||
element.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTables(selector: string, tables: DemoSqlTableSnapshot[]): void {
|
||||
const tbody = document.querySelector<HTMLTableSectionElement>(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<HTMLElement>(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;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// file: kb_app_demo/frontend/ts/demo_ws.ts
|
||||
// version: 6
|
||||
|
||||
import * as bootstrap from "bootstrap";
|
||||
import "simplebar";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
import {invoke} from "@tauri-apps/api/core";
|
||||
import {listen} from "@tauri-apps/api/event";
|
||||
import {frontendDebug, frontendError, installFrontendConsoleBridge} from "./frontend_log";
|
||||
import type {DemoWsExecutionPayload} from "./bindings/kb_app_demo/demo_ws/DemoWsExecutionPayload";
|
||||
import type {DemoWsMessagePayload} from "./bindings/kb_app_demo/demo_ws/DemoWsMessagePayload";
|
||||
import type {DemoWsMethodOption} from "./bindings/kb_app_demo/demo_ws/DemoWsMethodOption";
|
||||
import type {DemoWsOptionsPayload} from "./bindings/kb_app_demo/demo_ws/DemoWsOptionsPayload";
|
||||
import type {DemoWsRequest} from "./bindings/kb_app_demo/demo_ws/DemoWsRequest";
|
||||
import type {DemoWsRoleOption} from "./bindings/kb_app_demo/demo_ws/DemoWsRoleOption";
|
||||
import type {DemoWsStatusPayload} from "./bindings/kb_app_demo/demo_ws/DemoWsStatusPayload";
|
||||
import type {DemoWsUnsubscribeRequest} from "./bindings/kb_app_demo/demo_ws/DemoWsUnsubscribeRequest";
|
||||
|
||||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
|
||||
let roleOptions: DemoWsRoleOption[] = [];
|
||||
let methodOptions: DemoWsMethodOption[] = [];
|
||||
|
||||
const maxResultOutputCharacters = 200_000;
|
||||
const maxIncomingMessageCharacters = 25_000;
|
||||
|
||||
function textInputValue(selector: string): string {
|
||||
const element = document.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(selector);
|
||||
return element ? element.value.trim() : "";
|
||||
}
|
||||
|
||||
function writeTextarea(selector: string, value: string): void {
|
||||
const element = document.querySelector<HTMLTextAreaElement>(selector);
|
||||
if (element) {
|
||||
element.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function truncateIncomingMessage(value: string): string {
|
||||
if (value.length <= maxIncomingMessageCharacters) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, maxIncomingMessageCharacters)}\n... message tronqué côté UI ...`;
|
||||
}
|
||||
|
||||
function capTextareaValue(value: string): string {
|
||||
if (value.length <= maxResultOutputCharacters) {
|
||||
return value;
|
||||
}
|
||||
return `... historique tronqué côté UI ...\n${value.slice(value.length - maxResultOutputCharacters)}`;
|
||||
}
|
||||
|
||||
function appendTextarea(selector: string, value: string): void {
|
||||
const element = document.querySelector<HTMLTextAreaElement>(selector);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const previous = element.value.trim();
|
||||
const safeValue = truncateIncomingMessage(value);
|
||||
const nextValue = previous.length > 0 ? `${previous}\n\n${safeValue}` : safeValue;
|
||||
element.value = capTextareaValue(nextValue);
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
|
||||
function jsonText(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function roleSupportsMethod(role: DemoWsRoleOption, method: DemoWsMethodOption): boolean {
|
||||
return role.requestKinds.includes("*") || role.requestKinds.includes(method.requestKind);
|
||||
}
|
||||
|
||||
function selectedRole(): DemoWsRoleOption | null {
|
||||
const role = textInputValue("#wsRoleSelect");
|
||||
return roleOptions.find(option => option.role === role) ?? null;
|
||||
}
|
||||
|
||||
function populateSelect(selector: string, values: Array<{ value: string; label: string }>): void {
|
||||
const select = document.querySelector<HTMLSelectElement>(selector);
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
select.textContent = "";
|
||||
for (const item of values) {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.value;
|
||||
option.textContent = item.label;
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshMethodList(): void {
|
||||
const role = selectedRole();
|
||||
const filtered = role ? methodOptions.filter(method => roleSupportsMethod(role, method)) : methodOptions;
|
||||
populateSelect("#wsMethodSelect", filtered.map(method => ({
|
||||
value: method.method,
|
||||
label: `${method.method} — ${method.label}`,
|
||||
})));
|
||||
refreshMethodFields();
|
||||
}
|
||||
|
||||
function refreshMethodFields(): void {
|
||||
const methodName = textInputValue("#wsMethodSelect");
|
||||
const method = methodOptions.find(option => option.method === methodName) ?? null;
|
||||
const target = document.querySelector<HTMLInputElement>("#wsTargetInput");
|
||||
const filter = document.querySelector<HTMLTextAreaElement>("#wsFilterInput");
|
||||
const config = document.querySelector<HTMLTextAreaElement>("#wsConfigInput");
|
||||
if (target) {
|
||||
target.disabled = method ? !method.requiresTarget : false;
|
||||
target.placeholder = method && method.requiresTarget ? "Target requis" : "Non requis pour cette méthode";
|
||||
}
|
||||
if (filter) {
|
||||
filter.disabled = method ? !method.requiresFilterJson : false;
|
||||
}
|
||||
if (config) {
|
||||
config.disabled = method ? !method.supportsConfigJson : false;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSubscriptionSelect(status: DemoWsStatusPayload): void {
|
||||
const select = document.querySelector<HTMLSelectElement>("#wsSubscriptionSelect");
|
||||
const unsubscribeButton = document.querySelector<HTMLButtonElement>("#unsubscribeWsButton");
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
const previous = select.value;
|
||||
select.textContent = "";
|
||||
for (const subscription of status.subscriptions) {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(subscription.subscriptionId);
|
||||
option.textContent = `${subscription.subscriptionId} · ${subscription.method}`;
|
||||
select.appendChild(option);
|
||||
}
|
||||
if (previous.length > 0) {
|
||||
select.value = previous;
|
||||
}
|
||||
if (unsubscribeButton) {
|
||||
unsubscribeButton.disabled = !status.connected || status.subscriptions.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setConnectedUi(status: DemoWsStatusPayload): void {
|
||||
const badge = document.querySelector<HTMLElement>("#wsStatusBadge");
|
||||
const connectButton = document.querySelector<HTMLButtonElement>("#connectWsButton");
|
||||
const disconnectButton = document.querySelector<HTMLButtonElement>("#disconnectWsButton");
|
||||
if (badge) {
|
||||
badge.textContent = status.connected ? `Connecté · ${status.subscriptionCount} subscription(s)` : "Déconnecté";
|
||||
badge.className = status.connected ? "badge text-bg-success" : "badge text-bg-secondary";
|
||||
}
|
||||
if (connectButton) {
|
||||
connectButton.disabled = false;
|
||||
}
|
||||
if (disconnectButton) {
|
||||
disconnectButton.disabled = !status.connected;
|
||||
}
|
||||
refreshSubscriptionSelect(status);
|
||||
writeTextarea("#wsStatusOutput", jsonText(status));
|
||||
}
|
||||
|
||||
function formatWsExecutionPayload(payload: DemoWsExecutionPayload): string {
|
||||
const header = {
|
||||
endpointName: payload.endpointName,
|
||||
provider: payload.provider,
|
||||
endpointUrl: payload.endpointUrl,
|
||||
role: payload.role,
|
||||
method: payload.method,
|
||||
requestKind: payload.requestKind,
|
||||
responseKind: payload.responseKind,
|
||||
subscriptionId: payload.subscriptionId,
|
||||
};
|
||||
return `${JSON.stringify(header, null, 2)}\n\n--- subscription response ---\n${payload.responseJson}`;
|
||||
}
|
||||
|
||||
async function copyTextarea(selector: string): Promise<void> {
|
||||
const element = document.querySelector<HTMLTextAreaElement>(selector);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(element.value);
|
||||
}
|
||||
|
||||
async function refreshWsOptions(): Promise<void> {
|
||||
try {
|
||||
const options = await invoke<DemoWsOptionsPayload>("demo_ws_options");
|
||||
roleOptions = options.roles;
|
||||
methodOptions = options.methods;
|
||||
populateSelect("#wsRoleSelect", roleOptions.map(role => ({
|
||||
value: role.role,
|
||||
label: role.role,
|
||||
})));
|
||||
refreshMethodList();
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
writeTextarea("#wsResultOutput", `Erreur options WebSocket : ${message}`);
|
||||
frontendError("kb_app_demo.frontend.demo_ws", `WebSocket options loading failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWsPool(): Promise<void> {
|
||||
try {
|
||||
const snapshots = await invoke("demo_ws_list_pool_clients");
|
||||
writeTextarea("#wsPoolOutput", jsonText(snapshots));
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
writeTextarea("#wsPoolOutput", `Erreur : ${message}`);
|
||||
frontendError("kb_app_demo.frontend.demo_ws", `WebSocket pool refresh failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWsStatus(): Promise<void> {
|
||||
try {
|
||||
const status = await invoke<DemoWsStatusPayload>("demo_ws_status");
|
||||
setConnectedUi(status);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
writeTextarea("#wsStatusOutput", `Erreur statut WebSocket : ${message}`);
|
||||
frontendError("kb_app_demo.frontend.demo_ws", `WebSocket status failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWsRequest(): Promise<void> {
|
||||
const request: DemoWsRequest = {
|
||||
role: textInputValue("#wsRoleSelect"),
|
||||
method: textInputValue("#wsMethodSelect"),
|
||||
target: textInputValue("#wsTargetInput") || null,
|
||||
filterJson: textInputValue("#wsFilterInput") || null,
|
||||
configJson: textInputValue("#wsConfigInput") || null,
|
||||
};
|
||||
writeTextarea("#wsResultOutput", "Souscription en cours sur la connexion WebSocket...");
|
||||
try {
|
||||
const response = await invoke<DemoWsExecutionPayload>("demo_ws_connect", { request });
|
||||
writeTextarea("#wsResultOutput", formatWsExecutionPayload(response));
|
||||
frontendDebug("kb_app_demo.frontend.demo_ws", `WebSocket subscribed: ${response.method}`);
|
||||
await refreshWsStatus();
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
writeTextarea("#wsResultOutput", `Erreur : ${message}`);
|
||||
frontendError("kb_app_demo.frontend.demo_ws", `WebSocket subscription failed: ${message}`);
|
||||
await refreshWsStatus();
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribeWsRequest(): Promise<void> {
|
||||
const subscriptionIdText = textInputValue("#wsSubscriptionSelect");
|
||||
if (subscriptionIdText.length === 0) {
|
||||
appendTextarea("#wsResultOutput", "Aucune subscription sélectionnée.");
|
||||
return;
|
||||
}
|
||||
const subscriptionId = Number(subscriptionIdText);
|
||||
if (!Number.isSafeInteger(subscriptionId) || subscriptionId < 0) {
|
||||
appendTextarea("#wsResultOutput", "Identifiant de subscription invalide.");
|
||||
return;
|
||||
}
|
||||
const request: DemoWsUnsubscribeRequest = {
|
||||
subscriptionId,
|
||||
};
|
||||
try {
|
||||
const status = await invoke<DemoWsStatusPayload>("demo_ws_unsubscribe", { request });
|
||||
setConnectedUi(status);
|
||||
appendTextarea("#wsResultOutput", `--- unsubscribe ---\nSubscription ${request.subscriptionId} désinscrite.`);
|
||||
frontendDebug("kb_app_demo.frontend.demo_ws", `WebSocket unsubscribed: ${request.subscriptionId}`);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
appendTextarea("#wsResultOutput", `Erreur unsubscribe : ${message}`);
|
||||
frontendError("kb_app_demo.frontend.demo_ws", `WebSocket unsubscribe failed: ${message}`);
|
||||
await refreshWsStatus();
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectWsRequest(): Promise<void> {
|
||||
try {
|
||||
const status = await invoke<DemoWsStatusPayload>("demo_ws_disconnect");
|
||||
setConnectedUi(status);
|
||||
appendTextarea("#wsResultOutput", "--- disconnect ---\nDéconnexion demandée.");
|
||||
frontendDebug("kb_app_demo.frontend.demo_ws", "WebSocket disconnected");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
appendTextarea("#wsResultOutput", `Erreur déconnexion : ${message}`);
|
||||
frontendError("kb_app_demo.frontend.demo_ws", `WebSocket disconnection failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function installWsEventListeners(): Promise<void> {
|
||||
await listen<DemoWsStatusPayload>("demo-ws-status", event => {
|
||||
setConnectedUi(event.payload);
|
||||
});
|
||||
await listen<DemoWsMessagePayload>("demo-ws-message", event => {
|
||||
appendTextarea("#wsResultOutput", `--- ${event.payload.kind} ---\n${event.payload.payloadJson}`);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
installFrontendConsoleBridge("kb_app_demo.frontend.demo_ws");
|
||||
frontendDebug("kb_app_demo.frontend.demo_ws", "WebSocket demo window loaded");
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
||||
document.querySelector<HTMLSelectElement>("#wsRoleSelect")?.addEventListener("change", refreshMethodList);
|
||||
document.querySelector<HTMLSelectElement>("#wsMethodSelect")?.addEventListener("change", refreshMethodFields);
|
||||
document.querySelector<HTMLButtonElement>("#connectWsButton")?.addEventListener("click", () => {
|
||||
void connectWsRequest();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#unsubscribeWsButton")?.addEventListener("click", () => {
|
||||
void unsubscribeWsRequest();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#disconnectWsButton")?.addEventListener("click", () => {
|
||||
void disconnectWsRequest();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#refreshWsPoolButton")?.addEventListener("click", () => {
|
||||
void refreshWsPool();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#copyWsPoolButton")?.addEventListener("click", () => {
|
||||
void copyTextarea("#wsPoolOutput");
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#copyWsResultButton")?.addEventListener("click", () => {
|
||||
void copyTextarea("#wsResultOutput");
|
||||
});
|
||||
void installWsEventListeners();
|
||||
void refreshWsOptions();
|
||||
void refreshWsPool();
|
||||
void refreshWsStatus();
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// file: kb_app_demo/frontend/ts/frontend_log.ts
|
||||
// version: 1
|
||||
|
||||
//! Frontend logging helpers that preserve explicit tracing targets.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { FrontendLogPayload } from "./bindings/kb_app_demo/frontend_log/FrontendLogPayload";
|
||||
|
||||
type FrontendLogLevel = "trace" | "debug" | "info" | "warn" | "error";
|
||||
|
||||
type ConsoleMethod = (...items: unknown[]) => void;
|
||||
|
||||
const originalConsole = {
|
||||
trace: console.trace.bind(console),
|
||||
debug: console.debug.bind(console),
|
||||
log: console.log.bind(console),
|
||||
info: console.info.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
};
|
||||
|
||||
function stringifyItem(item: unknown): string {
|
||||
if (item instanceof Error) {
|
||||
return item.stack ?? item.message;
|
||||
}
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(item);
|
||||
} catch (caughtError) {
|
||||
return String(item);
|
||||
}
|
||||
}
|
||||
|
||||
function formatMessage(items: unknown[]): string {
|
||||
return items.map(item => stringifyItem(item)).join(" ");
|
||||
}
|
||||
|
||||
async function sendFrontendLog(level: FrontendLogLevel, target: string, message: string): Promise<void> {
|
||||
const payload: FrontendLogPayload = {
|
||||
level,
|
||||
target,
|
||||
message,
|
||||
};
|
||||
try {
|
||||
await invoke("emit_frontend_log", { payload });
|
||||
} catch (caughtError) {
|
||||
originalConsole.error("frontend tracing bridge failed", caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
export function frontendTrace(target: string, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.trace(message);
|
||||
void sendFrontendLog("trace", target, message);
|
||||
}
|
||||
|
||||
export function frontendDebug(target: string, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.debug(message);
|
||||
void sendFrontendLog("debug", target, message);
|
||||
}
|
||||
|
||||
export function frontendInfo(target: string, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.info(message);
|
||||
void sendFrontendLog("info", target, message);
|
||||
}
|
||||
|
||||
export function frontendWarn(target: string, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.warn(message);
|
||||
void sendFrontendLog("warn", target, message);
|
||||
}
|
||||
|
||||
export function frontendError(target: string, ...items: unknown[]): void {
|
||||
const message = formatMessage(items);
|
||||
originalConsole.error(message);
|
||||
void sendFrontendLog("error", target, message);
|
||||
}
|
||||
|
||||
function buildConsoleBridge(level: FrontendLogLevel, target: string, original: ConsoleMethod): ConsoleMethod {
|
||||
return (...items: unknown[]) => {
|
||||
original(...items);
|
||||
void sendFrontendLog(level, target, formatMessage(items));
|
||||
};
|
||||
}
|
||||
|
||||
export function installFrontendConsoleBridge(target: string): void {
|
||||
console.trace = buildConsoleBridge("trace", target, originalConsole.trace);
|
||||
console.debug = buildConsoleBridge("debug", target, originalConsole.debug);
|
||||
console.log = buildConsoleBridge("info", target, originalConsole.log);
|
||||
console.info = buildConsoleBridge("info", target, originalConsole.info);
|
||||
console.warn = buildConsoleBridge("warn", target, originalConsole.warn);
|
||||
console.error = buildConsoleBridge("error", target, originalConsole.error);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// file: kb_app_demo/frontend/ts/main.ts
|
||||
// version: 14
|
||||
|
||||
import * as bootstrap from "bootstrap";
|
||||
import "simplebar";
|
||||
import ResizeObserver from "resize-observer-polyfill";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log";
|
||||
|
||||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
|
||||
const markdownRenderer = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
});
|
||||
|
||||
function renderMarkdown(markdownText: string): string {
|
||||
return markdownRenderer.render(markdownText);
|
||||
}
|
||||
|
||||
async function loadReadme(readmeContent: HTMLElement): Promise<void> {
|
||||
try {
|
||||
const readme = await invoke<string>("load_project_readme");
|
||||
readmeContent.innerHTML = renderMarkdown(readme);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
readmeContent.textContent = `Erreur pendant le chargement du README : ${message}`;
|
||||
frontendError("kb_app_demo.frontend.main", `README loading failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDemoConfigWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_config_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `Configuration demo window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDemoHttpWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_http_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `HTTP demo window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function openDemoSqlDiagWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_sql_diag_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `SQL diagnostics demo window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDemoSqlPgRawWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_sql_pg_raw_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `PostgreSQL raw demo window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDemoSqlPgCoreWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_sql_pg_core_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `PostgreSQL core demo window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDemoSqlReplayCandidatesWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_sql_replay_candidates_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `SQL replay candidates window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDemoWsWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_ws_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `WebSocket demo window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDemoBackfillWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_backfill_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `Backfill demo window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDemoCoreExtractionWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_core_extraction_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `Core extraction demo window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDemoDecodeReplayWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_decode_replay_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `Decode replay demo window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDemoExecutionSolanaCoreWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_execution_solana_core_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `Solana Devnet execution window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDemoExecutionSplWindow(): Promise<void> {
|
||||
try {
|
||||
await invoke("open_demo_execution_spl_window");
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||
frontendError("kb_app_demo.frontend.main", `SPL Devnet execution window opening failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
installFrontendConsoleBridge("kb_app_demo.frontend.main");
|
||||
|
||||
frontendDebug("kb_app_demo.frontend.main", "main window loaded");
|
||||
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
||||
const toastElList = document.querySelectorAll('.toast');
|
||||
Array.from(toastElList).map(toastEl => new bootstrap.Toast(toastEl));
|
||||
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]');
|
||||
Array.from(popoverTriggerList).map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl));
|
||||
|
||||
const readmeContent = document.querySelector<HTMLElement>("#readmeContent");
|
||||
if (readmeContent) {
|
||||
await loadReadme(readmeContent);
|
||||
}
|
||||
|
||||
const openDemoConfigLink = document.querySelector<HTMLAnchorElement>("#openDemoConfigLink");
|
||||
if (openDemoConfigLink) {
|
||||
openDemoConfigLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoConfigWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const openDemoHttpLink = document.querySelector<HTMLAnchorElement>("#openDemoHttpLink");
|
||||
if (openDemoHttpLink) {
|
||||
openDemoHttpLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoHttpWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const openDemoWsLink = document.querySelector<HTMLAnchorElement>("#openDemoWsLink");
|
||||
if (openDemoWsLink) {
|
||||
openDemoWsLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoWsWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const openDemoBackfillLink = document.querySelector<HTMLAnchorElement>("#openDemoBackfillLink");
|
||||
if (openDemoBackfillLink) {
|
||||
openDemoBackfillLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoBackfillWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const openDemoCoreExtractionLink = document.querySelector<HTMLAnchorElement>("#openDemoCoreExtractionLink");
|
||||
if (openDemoCoreExtractionLink) {
|
||||
openDemoCoreExtractionLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoCoreExtractionWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const openDemoDecodeReplayLink = document.querySelector<HTMLAnchorElement>("#openDemoDecodeReplayLink");
|
||||
if (openDemoDecodeReplayLink) {
|
||||
openDemoDecodeReplayLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoDecodeReplayWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const openDemoExecutionSolanaCoreLink = document.querySelector<HTMLAnchorElement>("#openDemoExecutionSolanaCoreLink");
|
||||
if (openDemoExecutionSolanaCoreLink) {
|
||||
openDemoExecutionSolanaCoreLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoExecutionSolanaCoreWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const openDemoExecutionSplLink = document.querySelector<HTMLAnchorElement>("#openDemoExecutionSplLink");
|
||||
if (openDemoExecutionSplLink) {
|
||||
openDemoExecutionSplLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoExecutionSplWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const openDemoSqlDiagLink = document.querySelector<HTMLAnchorElement>("#openDemoSqlDiagLink");
|
||||
if (openDemoSqlDiagLink) {
|
||||
openDemoSqlDiagLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoSqlDiagWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const openDemoSqlPgRawLink = document.querySelector<HTMLAnchorElement>("#openDemoSqlPgRawLink");
|
||||
if (openDemoSqlPgRawLink) {
|
||||
openDemoSqlPgRawLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoSqlPgRawWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const openDemoSqlPgCoreLink = document.querySelector<HTMLAnchorElement>("#openDemoSqlPgCoreLink");
|
||||
if (openDemoSqlPgCoreLink) {
|
||||
openDemoSqlPgCoreLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoSqlPgCoreWindow();
|
||||
});
|
||||
}
|
||||
|
||||
const openDemoSqlReplayCandidatesLink = document.querySelector<HTMLAnchorElement>("#openDemoSqlReplayCandidatesLink");
|
||||
if (openDemoSqlReplayCandidatesLink) {
|
||||
openDemoSqlReplayCandidatesLink.addEventListener("click", event => {
|
||||
event.preventDefault();
|
||||
void openDemoSqlReplayCandidatesWindow();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// file: kb_app_demo/frontend/ts/splash.ts
|
||||
// version: 4
|
||||
|
||||
//! Splash-screen event handling for the Tauri startup window.
|
||||
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { SplashOrder } from "./bindings/kb_app_demo/splash/SplashOrder";
|
||||
import { frontendError, frontendDebug, installFrontendConsoleBridge } from "./frontend_log";
|
||||
|
||||
|
||||
const splashTarget = "kb_app_demo.frontend.splash";
|
||||
const defaultFadeDurationMs = 3000;
|
||||
let activeOpacityFrame: number | null = null;
|
||||
|
||||
function normalizeDurationMs(value: number | null | undefined): number {
|
||||
if (typeof value !== "number") {
|
||||
return defaultFadeDurationMs;
|
||||
}
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return defaultFadeDurationMs;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function easeInOut(progress: number): number {
|
||||
if (progress < 0.5) {
|
||||
return 2 * progress * progress;
|
||||
}
|
||||
return 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
||||
}
|
||||
|
||||
async function animateOpacity(element: HTMLElement, fromOpacity: number, toOpacity: number, durationMs: number): Promise<void> {
|
||||
frontendDebug(splashTarget, `Animating from ${fromOpacity} to ${toOpacity} over ${durationMs}ms`);
|
||||
if (activeOpacityFrame !== null) {
|
||||
cancelAnimationFrame(activeOpacityFrame);
|
||||
activeOpacityFrame = null;
|
||||
}
|
||||
element.style.opacity = fromOpacity.toString();
|
||||
element.style.willChange = "opacity";
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
|
||||
await new Promise<void>(resolve => {
|
||||
const startedAt = performance.now();
|
||||
const opacityDelta = toOpacity - fromOpacity;
|
||||
const updateOpacity = (currentTime: number): void => {
|
||||
const elapsedMs = currentTime - startedAt;
|
||||
const rawProgress = Math.min(elapsedMs / durationMs, 1);
|
||||
const easedProgress = easeInOut(rawProgress);
|
||||
const nextOpacity = fromOpacity + opacityDelta * easedProgress;
|
||||
element.style.opacity = nextOpacity.toString();
|
||||
if (rawProgress >= 1) {
|
||||
element.style.opacity = toOpacity.toString();
|
||||
element.style.willChange = "auto";
|
||||
activeOpacityFrame = null;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
activeOpacityFrame = requestAnimationFrame(updateOpacity);
|
||||
};
|
||||
activeOpacityFrame = requestAnimationFrame(updateOpacity);
|
||||
});
|
||||
}
|
||||
|
||||
function addLogMessage(message: string): void {
|
||||
const debugInfo = document.getElementById("debug-info");
|
||||
if (!debugInfo) {
|
||||
return;
|
||||
}
|
||||
const time = new Date().toLocaleTimeString();
|
||||
debugInfo.innerHTML += `${time}: ${message}<br>`;
|
||||
frontendDebug(splashTarget, `addLogMessage: ${message}`);
|
||||
}
|
||||
|
||||
function addMessage(message: string, status: string): void {
|
||||
const messagesContainer = document.getElementById("messages-container");
|
||||
if (!messagesContainer) {
|
||||
return;
|
||||
}
|
||||
const messageElement = document.createElement("div");
|
||||
messageElement.className = `splash-message ${status}`;
|
||||
messageElement.textContent = message;
|
||||
messagesContainer.appendChild(messageElement);
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
frontendDebug(splashTarget, `addMessage: ${message} - ${status}`);
|
||||
}
|
||||
|
||||
async function handleSplashOrder(order: SplashOrder): Promise<void> {
|
||||
const durationMs = normalizeDurationMs(order.duration_ms);
|
||||
const container = document.getElementById("splash-container");
|
||||
if (order.order === "fadein" && container) {
|
||||
frontendDebug(splashTarget, `fadein`);
|
||||
await animateOpacity(container, 0, 1, durationMs);
|
||||
return;
|
||||
}
|
||||
if (order.order === "fadeout" && container) {
|
||||
frontendDebug(splashTarget, `fadeout`);
|
||||
await animateOpacity(container, 1, 0, durationMs);
|
||||
return;
|
||||
}
|
||||
if (order.order === "add_msg" && order.msg && order.status) {
|
||||
addMessage(order.msg, order.status);
|
||||
return;
|
||||
}
|
||||
if (order.order === "add_log" && order.msg) {
|
||||
addLogMessage(order.msg);
|
||||
return;
|
||||
}
|
||||
frontendError(splashTarget, `unknown splash order: ${order.order}`);
|
||||
}
|
||||
|
||||
function initializeSplashDom(): void {
|
||||
const container = document.getElementById("splash-container");
|
||||
if (container) {
|
||||
container.style.opacity = "0";
|
||||
container.style.willChange = "opacity";
|
||||
}
|
||||
installFrontendConsoleBridge(splashTarget);
|
||||
}
|
||||
|
||||
async function initializeSplashListeners(): Promise<void> {
|
||||
await listen<SplashOrder>("splash", event => {
|
||||
void handleSplashOrder(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initializeSplashDom();
|
||||
void initializeSplashListeners();
|
||||
frontendDebug(splashTarget, "splash window loaded");
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
34
migration/khadhroony-bot2-reference/kb_app_demo/package.json
Normal file
34
migration/khadhroony-bot2-reference/kb_app_demo/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "kb-app-demo",
|
||||
"private": true,
|
||||
"version": "0.4.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@andypf/json-viewer": "^2.8",
|
||||
"@fltsci/tauri-plugin-tracing": "^0.3",
|
||||
"@fortawesome/fontawesome-free": "^7.2",
|
||||
"@tauri-apps/api": "^2.11",
|
||||
"bootstrap": "^5.3",
|
||||
"datatables.net-bs5": "^2.3",
|
||||
"datatables.net-select-bs5": "^3.1",
|
||||
"echarts": "^6.1",
|
||||
"markdown-it": "^14.2",
|
||||
"resize-observer-polyfill": "^1.5",
|
||||
"simplebar": "^6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11",
|
||||
"@types/bootstrap": "^5.2",
|
||||
"@types/markdown-it": "^14.1",
|
||||
"@types/node": "^25.9",
|
||||
"sass-embedded": "^1.100",
|
||||
"typescript": "^5.9",
|
||||
"vite": "^8.1"
|
||||
}
|
||||
}
|
||||
217
migration/khadhroony-bot2-reference/kb_app_demo/src/app_state.rs
Normal file
217
migration/khadhroony-bot2-reference/kb_app_demo/src/app_state.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
// file: kb_app_demo/src/app_state.rs
|
||||
// version: 11
|
||||
|
||||
//! Shared Tauri application state and startup state initialization.
|
||||
|
||||
/// Shared state managed by Tauri for the demo application.
|
||||
pub(crate) struct AppState {
|
||||
config_path: std::string::String,
|
||||
app_config: kb_config::AppConfig,
|
||||
active_profile: kb_config::ProfileConfig,
|
||||
logging_guard: std::sync::Mutex<kb_logging::LoggingGuard>,
|
||||
http_pool: kb_rpc::HttpEndpointPool,
|
||||
ws_pool: kb_rpc::WsEndpointPool,
|
||||
demo_ws_session: tokio::sync::Mutex<std::option::Option<std::sync::Arc<kb_rpc::WsSession>>>,
|
||||
demo_backfill_running: std::sync::atomic::AtomicBool,
|
||||
demo_backfill_cancel_requested: std::sync::atomic::AtomicBool,
|
||||
demo_core_extraction_running: std::sync::atomic::AtomicBool,
|
||||
demo_core_extraction_cancel_requested: std::sync::atomic::AtomicBool,
|
||||
demo_decode_replay_running: std::sync::atomic::AtomicBool,
|
||||
demo_decode_replay_cancel_requested: std::sync::atomic::AtomicBool,
|
||||
demo_decode_replay_campaign_id: std::sync::Mutex<std::option::Option<std::string::String>>,
|
||||
demo_execution_solana_core_running: std::sync::atomic::AtomicBool,
|
||||
demo_execution_solana_core_cancel_requested: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl crate::AppState {
|
||||
/// Initializes configuration, logging and shared runtime state.
|
||||
pub(crate) fn initialize() -> kb_core::Result<crate::AppState> {
|
||||
let config_path = resolve_config_path();
|
||||
let app_config = match kb_config::read_config_json_file(&config_path) {
|
||||
std::result::Result::Ok(config) => config,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let active_profile = match kb_config::active_profile(&app_config) {
|
||||
std::result::Result::Ok(profile) => profile.clone(),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let logging_config = convert_logging_config(&active_profile.logging);
|
||||
let logging_guard = match kb_logging::init_logging(&logging_config) {
|
||||
std::result::Result::Ok(guard) => guard,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let http_pool = match kb_rpc::HttpEndpointPool::from_profile(&active_profile) {
|
||||
std::result::Result::Ok(pool) => pool,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let ws_pool = match kb_rpc::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,
|
||||
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),
|
||||
demo_core_extraction_running: std::sync::atomic::AtomicBool::new(false),
|
||||
demo_core_extraction_cancel_requested: std::sync::atomic::AtomicBool::new(false),
|
||||
demo_decode_replay_running: std::sync::atomic::AtomicBool::new(false),
|
||||
demo_decode_replay_cancel_requested: std::sync::atomic::AtomicBool::new(false),
|
||||
demo_decode_replay_campaign_id: std::sync::Mutex::new(std::option::Option::None),
|
||||
demo_execution_solana_core_running: std::sync::atomic::AtomicBool::new(false),
|
||||
demo_execution_solana_core_cancel_requested: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the path used to load the configuration file.
|
||||
pub(crate) fn config_path(&self) -> &str {
|
||||
return self.config_path.as_str();
|
||||
}
|
||||
|
||||
/// Returns the complete parsed application configuration.
|
||||
pub(crate) fn app_config(&self) -> &kb_config::AppConfig {
|
||||
return &self.app_config;
|
||||
}
|
||||
|
||||
/// Returns the active profile selected from the configuration.
|
||||
pub(crate) fn active_profile(&self) -> &kb_config::ProfileConfig {
|
||||
return &self.active_profile;
|
||||
}
|
||||
|
||||
/// Returns the configured HTTP endpoint pool.
|
||||
pub(crate) fn http_pool(&self) -> &kb_rpc::HttpEndpointPool {
|
||||
return &self.http_pool;
|
||||
}
|
||||
|
||||
/// Returns the configured WebSocket endpoint pool.
|
||||
pub(crate) fn ws_pool(&self) -> &kb_rpc::WsEndpointPool {
|
||||
return &self.ws_pool;
|
||||
}
|
||||
|
||||
/// Returns the demo WebSocket runtime session slot.
|
||||
pub(crate) fn demo_ws_session(
|
||||
&self,
|
||||
) -> &tokio::sync::Mutex<std::option::Option<std::sync::Arc<kb_rpc::WsSession>>> {
|
||||
return &self.demo_ws_session;
|
||||
}
|
||||
|
||||
/// Returns the single-campaign execution flag used by the backfill demo.
|
||||
pub(crate) fn demo_backfill_running(&self) -> &std::sync::atomic::AtomicBool {
|
||||
return &self.demo_backfill_running;
|
||||
}
|
||||
|
||||
/// Returns the cooperative cancellation flag used by the backfill demo.
|
||||
pub(crate) fn demo_backfill_cancel_requested(&self) -> &std::sync::atomic::AtomicBool {
|
||||
return &self.demo_backfill_cancel_requested;
|
||||
}
|
||||
|
||||
/// Returns the single-campaign execution flag used by the core extraction demo.
|
||||
pub(crate) fn demo_core_extraction_running(&self) -> &std::sync::atomic::AtomicBool {
|
||||
return &self.demo_core_extraction_running;
|
||||
}
|
||||
|
||||
/// Returns the cooperative cancellation flag used by the core extraction demo.
|
||||
pub(crate) fn demo_core_extraction_cancel_requested(&self) -> &std::sync::atomic::AtomicBool {
|
||||
return &self.demo_core_extraction_cancel_requested;
|
||||
}
|
||||
|
||||
/// Returns the single-campaign execution flag used by the decode replay demo.
|
||||
pub(crate) fn demo_decode_replay_running(&self) -> &std::sync::atomic::AtomicBool {
|
||||
return &self.demo_decode_replay_running;
|
||||
}
|
||||
|
||||
/// Returns the cooperative cancellation flag used by the decode replay demo.
|
||||
pub(crate) fn demo_decode_replay_cancel_requested(&self) -> &std::sync::atomic::AtomicBool {
|
||||
return &self.demo_decode_replay_cancel_requested;
|
||||
}
|
||||
|
||||
/// Returns the active contextual decode campaign identifier slot.
|
||||
pub(crate) fn demo_decode_replay_campaign_id(
|
||||
&self,
|
||||
) -> &std::sync::Mutex<std::option::Option<std::string::String>> {
|
||||
return &self.demo_decode_replay_campaign_id;
|
||||
}
|
||||
|
||||
/// Returns the single-execution flag used by the Solana Core execution demo.
|
||||
pub(crate) fn demo_execution_solana_core_running(&self) -> &std::sync::atomic::AtomicBool {
|
||||
return &self.demo_execution_solana_core_running;
|
||||
}
|
||||
|
||||
/// Returns the cooperative cancellation flag used by the execution demo.
|
||||
pub(crate) fn demo_execution_solana_core_cancel_requested(
|
||||
&self,
|
||||
) -> &std::sync::atomic::AtomicBool {
|
||||
return &self.demo_execution_solana_core_cancel_requested;
|
||||
}
|
||||
|
||||
/// Returns the number of logging routes held by the logging guard.
|
||||
pub(crate) fn logging_route_count(&self) -> usize {
|
||||
let lock_result = self.logging_guard.lock();
|
||||
let guard = match lock_result {
|
||||
std::result::Result::Ok(guard) => guard,
|
||||
std::result::Result::Err(_) => return 0,
|
||||
};
|
||||
return guard.route_count();
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_logging_config(config: &kb_config::LoggingConfig) -> kb_logging::LoggingConfig {
|
||||
let mut targets = std::vec::Vec::<kb_logging::LogTargetConfig>::new();
|
||||
for target in &config.targets {
|
||||
targets.push(kb_logging::LogTargetConfig {
|
||||
name: target.name.clone(),
|
||||
enabled: target.enabled,
|
||||
sink: target.sink.clone(),
|
||||
level: target.level.clone(),
|
||||
path: target.path.clone(),
|
||||
rotation: target.rotation.clone(),
|
||||
format: target.format.clone(),
|
||||
ansi: target.ansi,
|
||||
targets: target.targets.clone(),
|
||||
});
|
||||
}
|
||||
let mut target_filters = std::vec::Vec::<kb_logging::LogTargetFilterConfig>::new();
|
||||
for filter in &config.target_filters {
|
||||
target_filters.push(kb_logging::LogTargetFilterConfig {
|
||||
target: filter.target.clone(),
|
||||
level: filter.level.clone(),
|
||||
});
|
||||
}
|
||||
return kb_logging::LoggingConfig {
|
||||
default_level: config.default_level.clone(),
|
||||
targets,
|
||||
target_filters,
|
||||
};
|
||||
}
|
||||
|
||||
fn resolve_config_path() -> std::path::PathBuf {
|
||||
let configured = std::env::var("KB_CONFIG_PATH").ok();
|
||||
return resolve_config_path_from_value(configured.as_deref());
|
||||
}
|
||||
|
||||
fn resolve_config_path_from_value(value: std::option::Option<&str>) -> std::path::PathBuf {
|
||||
if let std::option::Option::Some(configured) = value {
|
||||
let trimmed = configured.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return std::path::PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
return crate::workspace_root_dir().join("config/example.config.json");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn resolve_config_path_uses_workspace_example_by_default() {
|
||||
let path = super::resolve_config_path_from_value(std::option::Option::None);
|
||||
assert_eq!(
|
||||
path.file_name().and_then(std::ffi::OsStr::to_str),
|
||||
std::option::Option::Some("example.config.json")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// file: kb_app_demo/src/constants.rs
|
||||
// version: 1
|
||||
|
||||
//! Local constants for the `kb_app_demo` crate.
|
||||
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) const TRACING_TARGET: &str = "kb_app_demo";
|
||||
@@ -0,0 +1,457 @@
|
||||
// file: kb_app_demo/src/demo_backfill.rs
|
||||
// version: 9
|
||||
|
||||
//! Tauri commands and UI payloads for bounded HTTP transaction backfills.
|
||||
|
||||
use tauri::Emitter; // rust-rules: trait-import
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// One endpoint role capable of discovering and hydrating transaction history.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_backfill/DemoBackfillRoleOption.ts"
|
||||
)]
|
||||
pub(crate) struct DemoBackfillRoleOption {
|
||||
/// Stable endpoint role code.
|
||||
pub(crate) role: std::string::String,
|
||||
/// Providers exposing this role.
|
||||
pub(crate) providers: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Initial options shown by the backfill demo.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_backfill/DemoBackfillOptionsPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoBackfillOptionsPayload {
|
||||
/// Selectable endpoint roles.
|
||||
pub(crate) roles: std::vec::Vec<DemoBackfillRoleOption>,
|
||||
/// Preferred role when configured.
|
||||
pub(crate) default_role: std::option::Option<std::string::String>,
|
||||
/// Default transaction commitment.
|
||||
pub(crate) default_commitment: std::string::String,
|
||||
/// Default history page size.
|
||||
pub(crate) default_page_size: u16,
|
||||
/// Default maximum number of history pages.
|
||||
pub(crate) default_max_pages: u32,
|
||||
/// Default hydration concurrency requested by the operator.
|
||||
pub(crate) default_max_concurrent_requests: u32,
|
||||
/// Default retries after the initial attempt.
|
||||
pub(crate) default_max_retries: u32,
|
||||
/// Whether one campaign is currently running.
|
||||
pub(crate) running: bool,
|
||||
}
|
||||
|
||||
/// UI request for one bounded backfill campaign.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_backfill/DemoBackfillRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoBackfillRequest {
|
||||
/// Endpoint role used for history and transaction requests.
|
||||
pub(crate) role: std::string::String,
|
||||
/// Commitment used by standard Solana HTTP requests.
|
||||
pub(crate) commitment: std::string::String,
|
||||
/// Source mode: explicit_signatures, program, token or pool.
|
||||
pub(crate) mode: std::string::String,
|
||||
/// Newline-separated signatures for explicit mode.
|
||||
pub(crate) signatures_text: std::option::Option<std::string::String>,
|
||||
/// Program, token mint or pool address for address history modes.
|
||||
pub(crate) address: std::option::Option<std::string::String>,
|
||||
/// Optional anchor transaction signature. It is required only for newer-history scans.
|
||||
pub(crate) anchor_signature: std::option::Option<std::string::String>,
|
||||
/// Direction relative to the anchor, or from the latest entry when `before` has no anchor.
|
||||
pub(crate) direction: std::option::Option<std::string::String>,
|
||||
/// Maximum number of address-history signatures to hydrate.
|
||||
pub(crate) limit: u32,
|
||||
/// Maximum RPC page size.
|
||||
pub(crate) page_size: u16,
|
||||
/// Maximum number of history pages inspected.
|
||||
pub(crate) max_pages: u32,
|
||||
/// Operator concurrency cap.
|
||||
pub(crate) max_concurrent_requests: u32,
|
||||
/// Retries after the initial HTTP attempt.
|
||||
pub(crate) max_retries: u32,
|
||||
}
|
||||
|
||||
/// One progress event emitted to the backfill window.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_backfill/DemoBackfillProgressPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoBackfillProgressPayload {
|
||||
/// RFC 3339 timestamp.
|
||||
pub(crate) timestamp: std::string::String,
|
||||
/// Stable severity code.
|
||||
pub(crate) level: std::string::String,
|
||||
/// Human-readable message.
|
||||
pub(crate) message: std::string::String,
|
||||
/// Completed candidate count when known.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) completed: std::option::Option<u64>,
|
||||
/// Total candidate count when known.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) total: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
/// Final UI-safe summary for one backfill campaign.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_backfill/DemoBackfillSummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoBackfillSummaryPayload {
|
||||
/// Unique capture session identifier.
|
||||
pub(crate) capture_session_id: std::string::String,
|
||||
/// Stable filter code.
|
||||
pub(crate) filter_code: std::string::String,
|
||||
/// Endpoint role.
|
||||
pub(crate) role: std::string::String,
|
||||
/// Provider used for hydration.
|
||||
pub(crate) provider: std::string::String,
|
||||
/// Endpoint code used for hydration.
|
||||
pub(crate) endpoint_code: std::string::String,
|
||||
/// Number of history pages fetched.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) pages_fetched: u64,
|
||||
/// Number of unique candidates selected.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) candidates_selected: u64,
|
||||
/// Number of candidates admitted to the bounded execution queue.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) candidates_started: u64,
|
||||
/// Number of candidates that reached a terminal outcome.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) candidates_completed: u64,
|
||||
/// Number of admitted candidates interrupted before a terminal outcome.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) candidates_cancelled: u64,
|
||||
/// Number of selected candidates never admitted after cancellation.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) candidates_not_started: u64,
|
||||
/// Number of transactions received and normalized.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) transactions_received: u64,
|
||||
/// Number of canonical rows inserted.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) canonical_inserted: u64,
|
||||
/// Number of canonical inserts skipped by idempotence.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) canonical_skipped: u64,
|
||||
/// Number of signatures skipped before hydration because they already existed.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) existing_skipped: u64,
|
||||
/// Number of missing transactions after retries.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) missing: u64,
|
||||
/// Number of failed candidates.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) failed: u64,
|
||||
/// Number of observation rows inserted.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) observations_inserted: u64,
|
||||
/// Number of source attempts.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) attempts: u64,
|
||||
/// Whether the operator cancelled the campaign.
|
||||
pub(crate) cancelled: bool,
|
||||
/// Optional cursor for continuing an older-history scan.
|
||||
pub(crate) resume_before_signature: std::option::Option<std::string::String>,
|
||||
/// Campaign start time.
|
||||
pub(crate) started_at: std::string::String,
|
||||
/// Campaign completion time.
|
||||
pub(crate) finished_at: std::string::String,
|
||||
}
|
||||
|
||||
pub(crate) struct DemoBackfillObserver<'a> {
|
||||
pub(crate) app_handle: tauri::AppHandle,
|
||||
pub(crate) cancel_requested: &'a std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl kb_pipeline::BackfillObserver for crate::DemoBackfillObserver<'_> {
|
||||
fn on_progress(&self, event: &kb_pipeline::BackfillProgressEvent) {
|
||||
let payload = crate::DemoBackfillProgressPayload {
|
||||
timestamp: event.timestamp.clone(),
|
||||
level: event.level.code().to_string(),
|
||||
message: event.message.clone(),
|
||||
completed: event.completed,
|
||||
total: event.total,
|
||||
};
|
||||
let emit_result =
|
||||
self.app_handle.emit_to("demo_backfill", "demo-backfill-progress", payload);
|
||||
if let std::result::Result::Err(error) = emit_result {
|
||||
tracing::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
"cannot emit backfill progress: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
return self.cancel_requested.load(std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct DemoBackfillRunGuard<'a> {
|
||||
pub(crate) running: &'a std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl std::ops::Drop for crate::DemoBackfillRunGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_role_options(
|
||||
snapshots: std::vec::Vec<kb_rpc::HttpPoolClientSnapshot>,
|
||||
) -> std::vec::Vec<DemoBackfillRoleOption> {
|
||||
let mut role_methods = std::collections::BTreeMap::<
|
||||
std::string::String,
|
||||
(
|
||||
std::collections::BTreeSet<std::string::String>,
|
||||
std::collections::BTreeSet<std::string::String>,
|
||||
),
|
||||
>::new();
|
||||
for snapshot in snapshots {
|
||||
for role in snapshot.roles {
|
||||
if !role.enabled {
|
||||
continue;
|
||||
}
|
||||
let entry = role_methods.entry(role.role).or_default();
|
||||
for request_kind in role.request_kinds {
|
||||
entry.0.insert(request_kind);
|
||||
}
|
||||
entry.1.insert(snapshot.provider.clone());
|
||||
}
|
||||
}
|
||||
let mut output = std::vec::Vec::new();
|
||||
for (role, (request_kinds, providers)) in role_methods {
|
||||
let supports_all = request_kinds.contains("*");
|
||||
if !supports_all
|
||||
&& (!request_kinds.contains("get_signatures_for_address")
|
||||
|| !request_kinds.contains("get_transaction"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
output.push(DemoBackfillRoleOption {
|
||||
role,
|
||||
providers: providers.into_iter().collect(),
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
pub(crate) fn build_demo_backfill_pipeline_request(
|
||||
request: DemoBackfillRequest,
|
||||
) -> std::result::Result<kb_pipeline::BackfillRequest, std::string::String> {
|
||||
let source_result = match request.mode.trim() {
|
||||
"explicit_signatures" => explicit_source(request.signatures_text.as_deref()),
|
||||
"program" => address_source(
|
||||
kb_pipeline::BackfillAddressKind::Program,
|
||||
request.address.as_deref(),
|
||||
request.anchor_signature.as_deref(),
|
||||
request.direction.as_deref(),
|
||||
request.limit,
|
||||
),
|
||||
"token" => address_source(
|
||||
kb_pipeline::BackfillAddressKind::Token,
|
||||
request.address.as_deref(),
|
||||
request.anchor_signature.as_deref(),
|
||||
request.direction.as_deref(),
|
||||
request.limit,
|
||||
),
|
||||
"pool" => address_source(
|
||||
kb_pipeline::BackfillAddressKind::Pool,
|
||||
request.address.as_deref(),
|
||||
request.anchor_signature.as_deref(),
|
||||
request.direction.as_deref(),
|
||||
request.limit,
|
||||
),
|
||||
_ => std::result::Result::Err("unsupported backfill mode".to_string()),
|
||||
};
|
||||
let source = match source_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let pipeline_request = kb_pipeline::BackfillRequest {
|
||||
role: request.role,
|
||||
commitment: request.commitment,
|
||||
source,
|
||||
page_size: request.page_size,
|
||||
max_pages: request.max_pages,
|
||||
max_concurrent_requests: request.max_concurrent_requests,
|
||||
max_retries: request.max_retries,
|
||||
};
|
||||
let validation_result = pipeline_request.validate();
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error.to_string());
|
||||
}
|
||||
return std::result::Result::Ok(pipeline_request);
|
||||
}
|
||||
|
||||
fn explicit_source(
|
||||
signatures_text: std::option::Option<&str>,
|
||||
) -> std::result::Result<kb_pipeline::BackfillSource, std::string::String> {
|
||||
let text = match signatures_text {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => "",
|
||||
};
|
||||
let signatures = text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|value| return !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
if signatures.is_empty() {
|
||||
return std::result::Result::Err(
|
||||
"the signatures textarea must contain at least one signature".to_string(),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(kb_pipeline::BackfillSource::ExplicitSignatures(signatures));
|
||||
}
|
||||
|
||||
fn address_source(
|
||||
kind: kb_pipeline::BackfillAddressKind,
|
||||
address: std::option::Option<&str>,
|
||||
anchor_signature: std::option::Option<&str>,
|
||||
direction: std::option::Option<&str>,
|
||||
limit: u32,
|
||||
) -> std::result::Result<kb_pipeline::BackfillSource, std::string::String> {
|
||||
let address_text = match address {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => "",
|
||||
};
|
||||
let address_value = address_text.trim();
|
||||
if address_value.is_empty() {
|
||||
return std::result::Result::Err("address is required".to_string());
|
||||
}
|
||||
let direction_text = match direction {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => "",
|
||||
};
|
||||
let direction_value = match direction_text.trim() {
|
||||
"before" => kb_pipeline::BackfillDirection::Before,
|
||||
"after" => kb_pipeline::BackfillDirection::After,
|
||||
_ => {
|
||||
return std::result::Result::Err("direction must be before or after".to_string());
|
||||
},
|
||||
};
|
||||
let anchor_value = anchor_signature
|
||||
.map(str::trim)
|
||||
.filter(|value| return !value.is_empty())
|
||||
.map(str::to_string);
|
||||
if direction_value == kb_pipeline::BackfillDirection::After && anchor_value.is_none() {
|
||||
return std::result::Result::Err(
|
||||
"anchor signature is required for newer-history backfill".to_string(),
|
||||
);
|
||||
}
|
||||
let limit_value = match usize::try_from(limit) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!("signature limit conversion failed: {error}"));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(kb_pipeline::BackfillSource::AddressHistory {
|
||||
kind,
|
||||
address: address_value.to_string(),
|
||||
anchor_signature: anchor_value,
|
||||
direction: direction_value,
|
||||
limit: limit_value,
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn demo_backfill_summary_payload(
|
||||
summary: kb_pipeline::BackfillSummary,
|
||||
) -> DemoBackfillSummaryPayload {
|
||||
return crate::DemoBackfillSummaryPayload {
|
||||
capture_session_id: summary.capture_session_id,
|
||||
filter_code: summary.filter_code,
|
||||
role: summary.role,
|
||||
provider: summary.provider,
|
||||
endpoint_code: summary.endpoint_code,
|
||||
pages_fetched: summary.pages_fetched,
|
||||
candidates_selected: summary.candidates_selected,
|
||||
candidates_started: summary.candidates_started,
|
||||
candidates_completed: summary.candidates_completed,
|
||||
candidates_cancelled: summary.candidates_cancelled,
|
||||
candidates_not_started: summary.candidates_not_started,
|
||||
transactions_received: summary.transactions_received,
|
||||
canonical_inserted: summary.canonical_inserted,
|
||||
canonical_skipped: summary.canonical_skipped,
|
||||
existing_skipped: summary.existing_skipped,
|
||||
missing: summary.missing,
|
||||
failed: summary.failed,
|
||||
observations_inserted: summary.observations_inserted,
|
||||
attempts: summary.attempts,
|
||||
cancelled: summary.cancelled,
|
||||
resume_before_signature: summary.resume_before_signature,
|
||||
started_at: summary.started_at,
|
||||
finished_at: summary.finished_at,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn explicit_source_splits_lines_and_ignores_empty_rows() {
|
||||
let result = super::explicit_source(std::option::Option::Some(" first \n\n second\n"));
|
||||
let source = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("source failed: {error}"),
|
||||
};
|
||||
assert_eq!(
|
||||
source,
|
||||
kb_pipeline::BackfillSource::ExplicitSignatures(std::vec![
|
||||
"first".to_string(),
|
||||
"second".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_history_accepts_missing_anchor_and_uses_latest_filter() {
|
||||
let source_result = super::address_source(
|
||||
kb_pipeline::BackfillAddressKind::Program,
|
||||
std::option::Option::Some("11111111111111111111111111111111"),
|
||||
std::option::Option::Some(" "),
|
||||
std::option::Option::Some("before"),
|
||||
100,
|
||||
);
|
||||
let source = match source_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("source failed: {error}"),
|
||||
};
|
||||
let request = kb_pipeline::BackfillRequest {
|
||||
role: "history_backfill".to_string(),
|
||||
commitment: "confirmed".to_string(),
|
||||
source,
|
||||
page_size: 100,
|
||||
max_pages: 1,
|
||||
max_concurrent_requests: 1,
|
||||
max_retries: 0,
|
||||
};
|
||||
assert!(request.validate().is_ok());
|
||||
assert_eq!(request.filter_code(), "program_latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_history_rejects_missing_anchor() {
|
||||
let source_result = super::address_source(
|
||||
kb_pipeline::BackfillAddressKind::Program,
|
||||
std::option::Option::Some("11111111111111111111111111111111"),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some("after"),
|
||||
100,
|
||||
);
|
||||
assert!(source_result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// file: kb_app_demo/src/demo_config.rs
|
||||
// version: 8
|
||||
|
||||
//! Configuration demo commands.
|
||||
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// Serializable payload shown by the configuration demo page.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_config/DemoConfigPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoConfigPayload {
|
||||
/// Path used to load the configuration file.
|
||||
pub(crate) config_path: std::string::String,
|
||||
/// Active profile name.
|
||||
pub(crate) active_profile_name: std::string::String,
|
||||
/// Active profile environment.
|
||||
pub(crate) environment: std::string::String,
|
||||
/// Entire parsed configuration.
|
||||
pub(crate) app_config: kb_config::AppConfig,
|
||||
/// Active profile configuration.
|
||||
pub(crate) active_profile: kb_config::ProfileConfig,
|
||||
/// Embedded JSON Schema text used during loading.
|
||||
pub(crate) schema_json: std::string::String,
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// file: kb_app_demo/src/demo_core_extraction.rs
|
||||
// version: 9
|
||||
|
||||
//! Tauri commands and UI payloads for canonical transaction to core extraction.
|
||||
|
||||
use tauri::Emitter; // rust-rules: trait-import
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// Initial options shown by the core extraction demo.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_core_extraction/DemoCoreExtractionOptionsPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoCoreExtractionOptionsPayload {
|
||||
/// Current extractor implementation version.
|
||||
pub(crate) processor_version: std::string::String,
|
||||
/// Default maximum selected canonical transactions.
|
||||
pub(crate) default_limit: u32,
|
||||
/// Default maximum concurrent extractions.
|
||||
pub(crate) default_max_concurrent_extractions: u32,
|
||||
/// Whether one extraction campaign is currently running.
|
||||
pub(crate) running: bool,
|
||||
}
|
||||
|
||||
/// UI request for one bounded core extraction campaign.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_core_extraction/DemoCoreExtractionRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoCoreExtractionRequest {
|
||||
/// Source mode: signatures, pending or slot_range.
|
||||
pub(crate) mode: std::string::String,
|
||||
/// Newline-separated signatures for exact selection.
|
||||
pub(crate) signatures_text: std::option::Option<std::string::String>,
|
||||
/// Optional program id already present in core instructions.
|
||||
pub(crate) program_id: std::option::Option<std::string::String>,
|
||||
/// Optional inclusive minimum slot.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) min_slot: std::option::Option<u64>,
|
||||
/// Optional inclusive maximum slot.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) max_slot: std::option::Option<u64>,
|
||||
/// Maximum selected canonical transactions.
|
||||
pub(crate) limit: u32,
|
||||
/// Maximum concurrent extraction operations.
|
||||
pub(crate) max_concurrent_extractions: u32,
|
||||
/// Forces replacement of already current core rows.
|
||||
pub(crate) force_replay: bool,
|
||||
}
|
||||
|
||||
/// One progress event emitted to the core extraction window.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_core_extraction/DemoCoreExtractionProgressPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoCoreExtractionProgressPayload {
|
||||
/// RFC 3339 timestamp.
|
||||
pub(crate) timestamp: std::string::String,
|
||||
/// Stable severity code.
|
||||
pub(crate) level: std::string::String,
|
||||
/// Human-readable message.
|
||||
pub(crate) message: std::string::String,
|
||||
/// Number of terminal candidates.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) completed: u64,
|
||||
/// Total selected candidates.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) total: u64,
|
||||
}
|
||||
|
||||
/// Final UI-safe summary for one extraction campaign.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_core_extraction/DemoCoreExtractionSummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoCoreExtractionSummaryPayload {
|
||||
/// Current extractor implementation version.
|
||||
pub(crate) processor_version: std::string::String,
|
||||
/// Number of selected canonical transactions.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) selected: u64,
|
||||
/// Number admitted to the bounded execution queue.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) started: u64,
|
||||
/// Number reaching a terminal result.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) completed: u64,
|
||||
/// Number skipped by version/hash idempotence.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) skipped: u64,
|
||||
/// Number extracted and committed.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) extracted: u64,
|
||||
/// Number failed.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) failed: u64,
|
||||
/// Number admitted but cancelled before a terminal result.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) cancelled_candidates: u64,
|
||||
/// Number selected but not started after cancellation.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) not_started: u64,
|
||||
/// Whether the campaign was cancelled.
|
||||
pub(crate) cancelled: bool,
|
||||
/// Campaign start timestamp.
|
||||
pub(crate) started_at: std::string::String,
|
||||
/// Campaign finish timestamp.
|
||||
pub(crate) finished_at: std::string::String,
|
||||
}
|
||||
|
||||
pub(crate) struct DemoCoreExtractionObserver<'a> {
|
||||
pub(crate) app_handle: tauri::AppHandle,
|
||||
pub(crate) cancel_requested: &'a std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl kb_pipeline::CoreExtractionObserver for crate::DemoCoreExtractionObserver<'_> {
|
||||
fn on_progress(&self, event: &kb_pipeline::CoreExtractionProgressEvent) {
|
||||
let payload = crate::DemoCoreExtractionProgressPayload {
|
||||
timestamp: event.timestamp.clone(),
|
||||
level: event.level.code().to_string(),
|
||||
message: event.message.clone(),
|
||||
completed: event.completed,
|
||||
total: event.total,
|
||||
};
|
||||
let emit_result = self.app_handle.emit_to(
|
||||
"demo_core_extraction",
|
||||
"demo-core-extraction-progress",
|
||||
payload,
|
||||
);
|
||||
if let std::result::Result::Err(error) = emit_result {
|
||||
tracing::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
"cannot emit core extraction progress: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
return self.cancel_requested.load(std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct DemoCoreExtractionRunGuard<'a> {
|
||||
pub(crate) running: &'a std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl std::ops::Drop for crate::DemoCoreExtractionRunGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_demo_core_extraction_pipeline_request(
|
||||
request: crate::DemoCoreExtractionRequest,
|
||||
) -> std::result::Result<kb_pipeline::CoreExtractionRequest, std::string::String> {
|
||||
let source_result = match request.mode.trim() {
|
||||
"signatures" => {
|
||||
let signatures = split_signatures(request.signatures_text.as_deref());
|
||||
std::result::Result::Ok(kb_pipeline::CoreExtractionSource::Signatures(signatures))
|
||||
},
|
||||
"pending" => std::result::Result::Ok(kb_pipeline::CoreExtractionSource::Pending),
|
||||
"program_id" => {
|
||||
let program_id = match request.program_id {
|
||||
std::option::Option::Some(value) if !value.trim().is_empty() => value,
|
||||
_ => {
|
||||
return std::result::Result::Err(
|
||||
"program id is required for program extraction".to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
std::result::Result::Ok(kb_pipeline::CoreExtractionSource::ProgramId { program_id })
|
||||
},
|
||||
"slot_range" => {
|
||||
let min_slot = match request.min_slot {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
"minimum slot is required for slot range extraction".to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
let max_slot = match request.max_slot {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
"maximum slot is required for slot range extraction".to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
std::result::Result::Ok(kb_pipeline::CoreExtractionSource::SlotRange {
|
||||
min_slot,
|
||||
max_slot,
|
||||
})
|
||||
},
|
||||
_ => std::result::Result::Err("unsupported core extraction mode".to_string()),
|
||||
};
|
||||
let source = match source_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let pipeline_request = kb_pipeline::CoreExtractionRequest {
|
||||
source,
|
||||
limit: request.limit,
|
||||
max_concurrent_extractions: request.max_concurrent_extractions,
|
||||
force_replay: request.force_replay,
|
||||
};
|
||||
let validation_result = pipeline_request.validate();
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error.to_string());
|
||||
}
|
||||
return std::result::Result::Ok(pipeline_request);
|
||||
}
|
||||
|
||||
fn split_signatures(text: std::option::Option<&str>) -> std::vec::Vec<std::string::String> {
|
||||
let source = match text {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => "",
|
||||
};
|
||||
let mut unique = std::collections::BTreeSet::<std::string::String>::new();
|
||||
let mut output = std::vec::Vec::new();
|
||||
for line in source.lines() {
|
||||
let signature = line.trim();
|
||||
if signature.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if unique.insert(signature.to_string()) {
|
||||
output.push(signature.to_string());
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
pub(crate) fn demo_core_extraction_summary_payload(
|
||||
summary: kb_pipeline::CoreExtractionSummary,
|
||||
) -> crate::DemoCoreExtractionSummaryPayload {
|
||||
return crate::DemoCoreExtractionSummaryPayload {
|
||||
processor_version: summary.processor_version,
|
||||
selected: summary.selected,
|
||||
started: summary.started,
|
||||
completed: summary.completed,
|
||||
skipped: summary.skipped,
|
||||
extracted: summary.extracted,
|
||||
failed: summary.failed,
|
||||
cancelled_candidates: summary.cancelled_candidates,
|
||||
not_started: summary.not_started,
|
||||
cancelled: summary.cancelled,
|
||||
started_at: summary.started_at,
|
||||
finished_at: summary.finished_at,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn signature_source_splits_deduplicates_and_ignores_empty_rows() {
|
||||
let values = super::split_signatures(std::option::Option::Some("alpha\n\n beta \nalpha\n"));
|
||||
assert_eq!(values, std::vec!["alpha".to_string(), "beta".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,953 @@
|
||||
// file: kb_app_demo/src/demo_decode_replay.rs
|
||||
// version: 24
|
||||
|
||||
//! Tauri commands and UI payloads for contextual instruction decode replay.
|
||||
|
||||
use tauri::Emitter; // rust-rules: trait-import
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// One decoder selectable by the decode replay demo.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_decode_replay/DemoDecodeReplayDecoderOption.ts"
|
||||
)]
|
||||
pub(crate) struct DemoDecodeReplayDecoderOption {
|
||||
/// Stable decoder name.
|
||||
pub(crate) name: std::string::String,
|
||||
/// Stable decoder version.
|
||||
pub(crate) version: std::string::String,
|
||||
/// Exact supported program identifiers.
|
||||
pub(crate) program_ids: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Initial options shown by the contextual decode replay demo.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_decode_replay/DemoDecodeReplayOptionsPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoDecodeReplayOptionsPayload {
|
||||
/// Common orchestration version.
|
||||
pub(crate) pipeline_version: std::string::String,
|
||||
/// Available contextual decoders.
|
||||
pub(crate) decoders: std::vec::Vec<crate::DemoDecodeReplayDecoderOption>,
|
||||
/// Stable names of materializers currently registered by the demo.
|
||||
pub(crate) materializer_names: std::vec::Vec<std::string::String>,
|
||||
/// Default bounded selection limit.
|
||||
pub(crate) default_limit: u32,
|
||||
/// Default maximum concurrent contextual inputs.
|
||||
pub(crate) default_max_concurrent_inputs: u32,
|
||||
/// Whether one replay campaign is running.
|
||||
pub(crate) running: bool,
|
||||
}
|
||||
|
||||
/// UI request for one bounded contextual decode replay campaign.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_decode_replay/DemoDecodeReplayRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoDecodeReplayRequest {
|
||||
/// Newline-separated exact transaction signatures.
|
||||
pub(crate) signatures_text: std::option::Option<std::string::String>,
|
||||
/// Optional exact program identifier.
|
||||
pub(crate) program_id: std::option::Option<std::string::String>,
|
||||
/// Instruction processing state code, actionable or incomplete_signatures.
|
||||
pub(crate) instruction_state: std::string::String,
|
||||
/// Newline-separated exact stable instruction paths.
|
||||
pub(crate) instruction_paths_text: std::option::Option<std::string::String>,
|
||||
/// Explicit selected decoder names.
|
||||
pub(crate) decoder_names: std::vec::Vec<std::string::String>,
|
||||
/// Maximum selected contextual inputs.
|
||||
pub(crate) limit: u32,
|
||||
/// Maximum concurrent contextual inputs.
|
||||
pub(crate) max_concurrent_inputs: u32,
|
||||
/// Whether every compatible decoder must run.
|
||||
pub(crate) all_compatible: bool,
|
||||
/// Replaces only processor-owned outputs for the same version and input.
|
||||
pub(crate) force_replay: bool,
|
||||
/// Explicitly authorizes a bounded force replay without exact signatures.
|
||||
pub(crate) force_replay_all_matching: bool,
|
||||
/// Runs compatible materializers after decoded event persistence.
|
||||
pub(crate) materialize_after_decode: bool,
|
||||
}
|
||||
|
||||
/// One progress event emitted to the decode replay window.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_decode_replay/DemoDecodeReplayProgressPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoDecodeReplayProgressPayload {
|
||||
/// Stable process-local campaign identifier.
|
||||
pub(crate) campaign_id: std::string::String,
|
||||
/// RFC 3339 timestamp.
|
||||
pub(crate) timestamp: std::string::String,
|
||||
/// Stable severity code.
|
||||
pub(crate) level: std::string::String,
|
||||
/// Human-readable message.
|
||||
pub(crate) message: std::string::String,
|
||||
/// Number of terminal contextual inputs.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) completed: u64,
|
||||
/// Total selected contextual inputs.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) total: u64,
|
||||
}
|
||||
|
||||
/// One processor counter row returned to the UI.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_decode_replay/DemoDecodeProcessorSummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoDecodeProcessorSummaryPayload {
|
||||
/// Stable processor name.
|
||||
pub(crate) processor_name: std::string::String,
|
||||
/// Stable processor version.
|
||||
pub(crate) processor_version: std::string::String,
|
||||
/// Compatible dispatch count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) dispatched: u64,
|
||||
/// Version and hash skip count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) skipped: u64,
|
||||
/// Decoded input count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) decoded: u64,
|
||||
/// Ignored input count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) ignored: u64,
|
||||
/// Unsupported input count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) unsupported: u64,
|
||||
/// Failed input count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) failed: u64,
|
||||
/// Materialized output count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) materialized_outputs: u64,
|
||||
/// Materialization refusal count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) materialization_refused: u64,
|
||||
}
|
||||
|
||||
/// Final UI-safe summary for one contextual decode replay campaign.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_decode_replay/DemoDecodeReplaySummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoDecodeReplaySummaryPayload {
|
||||
/// Stable process-local campaign identifier.
|
||||
pub(crate) campaign_id: std::string::String,
|
||||
/// Common pipeline version.
|
||||
pub(crate) pipeline_version: std::string::String,
|
||||
/// Number of selected contextual inputs.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) selected: u64,
|
||||
/// Number admitted to execution.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) started: u64,
|
||||
/// Number reaching a terminal state.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) completed: u64,
|
||||
/// Number with no compatible enabled decoder.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) unmatched: u64,
|
||||
/// Number never started after cancellation.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) not_started: u64,
|
||||
/// Number of failed contextual inputs.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) failed_inputs: u64,
|
||||
/// Whether cancellation was observed.
|
||||
pub(crate) cancelled: bool,
|
||||
/// Per-processor counters.
|
||||
pub(crate) processors: std::vec::Vec<crate::DemoDecodeProcessorSummaryPayload>,
|
||||
/// Campaign start timestamp.
|
||||
pub(crate) started_at: std::string::String,
|
||||
/// Campaign finish timestamp.
|
||||
pub(crate) finished_at: std::string::String,
|
||||
}
|
||||
|
||||
/// One aggregated coverage row returned to the UI.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_decode_replay/DemoDecodeCoverageSummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoDecodeCoverageSummaryPayload {
|
||||
/// Stable processor name.
|
||||
pub(crate) processor_name: std::string::String,
|
||||
/// Stable processor version.
|
||||
pub(crate) processor_version: std::string::String,
|
||||
/// Exact program identifier.
|
||||
pub(crate) program_id: std::string::String,
|
||||
/// Optional stable surface code.
|
||||
pub(crate) surface_code: std::option::Option<std::string::String>,
|
||||
/// Stable entry classifier.
|
||||
pub(crate) entry_code: std::string::String,
|
||||
/// Declared count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) declared_count: i64,
|
||||
/// Observed count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) observed_count: i64,
|
||||
/// Recognized count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) recognized_count: i64,
|
||||
/// Decoded observation count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) decoded_count: i64,
|
||||
/// Materialized output count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) materialized_count: i64,
|
||||
/// Error count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) error_count: i64,
|
||||
/// Unknown or unsupported observation count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) unknown_count: i64,
|
||||
/// Successful source transaction count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) successful_transaction_count: i64,
|
||||
/// Failed source transaction count.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) failed_transaction_count: i64,
|
||||
}
|
||||
|
||||
/// Read-only decode, ledger and coverage diagnostics.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_decode_replay/DemoDecodeDiagnosticsPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoDecodeDiagnosticsPayload {
|
||||
/// Decode, materialization and ledger table diagnostics.
|
||||
pub(crate) tables: std::vec::Vec<crate::DemoSqlTableSnapshot>,
|
||||
/// Aggregated declared and observed coverage.
|
||||
pub(crate) coverage: std::vec::Vec<crate::DemoDecodeCoverageSummaryPayload>,
|
||||
}
|
||||
|
||||
/// Bounded read request for committed transaction annotations.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_decode_replay/DemoTransactionAnnotationRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoTransactionAnnotationRequest {
|
||||
/// Optional partial transaction signature.
|
||||
pub(crate) signature_contains: std::option::Option<std::string::String>,
|
||||
/// Maximum returned rows.
|
||||
pub(crate) limit: u32,
|
||||
}
|
||||
|
||||
/// UI-safe committed transaction annotation row.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_decode_replay/DemoTransactionAnnotationRow.ts"
|
||||
)]
|
||||
pub(crate) struct DemoTransactionAnnotationRow {
|
||||
/// Materializer processor version.
|
||||
pub(crate) processor_version: std::string::String,
|
||||
/// Source decoder and version.
|
||||
pub(crate) decoder: std::string::String,
|
||||
/// Transaction signature.
|
||||
pub(crate) signature: std::string::String,
|
||||
/// Decimal slot rendered as text to preserve JSON precision.
|
||||
pub(crate) slot: std::string::String,
|
||||
/// Stable outer or inner instruction path.
|
||||
pub(crate) instruction_path: std::string::String,
|
||||
/// Exact Memo generation.
|
||||
pub(crate) generation: std::string::String,
|
||||
/// Exact Memo Program ID.
|
||||
pub(crate) program_id: std::string::String,
|
||||
/// Complete bounded UTF-8 Memo text.
|
||||
pub(crate) text: std::string::String,
|
||||
/// Exact payload byte length.
|
||||
pub(crate) payload_length_bytes: u32,
|
||||
/// Canonical payload SHA-256.
|
||||
pub(crate) payload_sha256: std::string::String,
|
||||
/// Runtime-verified ordered signer keys.
|
||||
pub(crate) verified_signers: std::vec::Vec<std::string::String>,
|
||||
/// Stable materializer idempotence key.
|
||||
pub(crate) idempotence_key: std::string::String,
|
||||
/// Database creation timestamp.
|
||||
pub(crate) created_at: std::string::String,
|
||||
/// Database replacement timestamp.
|
||||
pub(crate) updated_at: std::string::String,
|
||||
}
|
||||
|
||||
pub(crate) struct DemoDecodeReplayObserver<'a> {
|
||||
pub(crate) app_handle: tauri::AppHandle,
|
||||
pub(crate) campaign_id: std::string::String,
|
||||
pub(crate) cancel_requested: &'a std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl kb_pipeline::DecodeReplayObserver for crate::DemoDecodeReplayObserver<'_> {
|
||||
fn on_progress(&self, event: &kb_pipeline::DecodeReplayProgressEvent) {
|
||||
let payload = DemoDecodeReplayProgressPayload {
|
||||
campaign_id: self.campaign_id.clone(),
|
||||
timestamp: event.timestamp.clone(),
|
||||
level: event.level.code().to_string(),
|
||||
message: event.message.clone(),
|
||||
completed: event.completed,
|
||||
total: event.total,
|
||||
};
|
||||
let emit_result =
|
||||
self.app_handle
|
||||
.emit_to("demo_decode_replay", "demo-decode-replay-progress", payload);
|
||||
if let std::result::Result::Err(error) = emit_result {
|
||||
tracing::warn!(target: crate::TRACING_TARGET, action = "emit_progress", campaign_id = %self.campaign_id, error = %error, "cannot emit decode replay progress");
|
||||
}
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
return self.cancel_requested.load(std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct DemoDecodeReplayRunGuard<'a> {
|
||||
pub(crate) running: &'a std::sync::atomic::AtomicBool,
|
||||
pub(crate) campaign_id: &'a std::sync::Mutex<std::option::Option<std::string::String>>,
|
||||
}
|
||||
|
||||
impl std::ops::Drop for crate::DemoDecodeReplayRunGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, std::sync::atomic::Ordering::Release);
|
||||
let lock_result = self.campaign_id.lock();
|
||||
if let std::result::Result::Ok(mut active_campaign_id) = lock_result {
|
||||
*active_campaign_id = std::option::Option::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register_active_campaign(
|
||||
campaign_slot: &std::sync::Mutex<std::option::Option<std::string::String>>,
|
||||
campaign_id: &str,
|
||||
) -> std::result::Result<(), std::string::String> {
|
||||
let lock_result = campaign_slot.lock();
|
||||
let mut active_campaign_id = match lock_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(
|
||||
"cannot register active contextual decode campaign".to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
*active_campaign_id = std::option::Option::Some(campaign_id.to_string());
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
pub(crate) fn available_materializers()
|
||||
-> std::vec::Vec<std::sync::Arc<dyn kb_materializer_api::EventMaterializer>> {
|
||||
return std::vec![
|
||||
std::sync::Arc::new(kb_materializer_admin::AdminMaterializer),
|
||||
std::sync::Arc::new(kb_materializer_compliance_audit::ComplianceAuditMaterializer),
|
||||
std::sync::Arc::new(kb_materializer_fees::FeesMaterializer),
|
||||
std::sync::Arc::new(kb_materializer_lifecycle::LifecycleMaterializer),
|
||||
std::sync::Arc::new(kb_materializer_staking::StakingMaterializer),
|
||||
std::sync::Arc::new(kb_materializer_token_accounts::TokenAccountsMaterializer),
|
||||
std::sync::Arc::new(kb_materializer_risk::RiskMaterializer),
|
||||
std::sync::Arc::new(
|
||||
kb_materializer_transaction_annotations::TransactionAnnotationMaterializer,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
pub(crate) fn available_decoders()
|
||||
-> std::vec::Vec<std::sync::Arc<dyn kb_decoder_api::InstructionDecoder>> {
|
||||
return std::vec![
|
||||
std::sync::Arc::new(kb_decoder_solana_core::SolanaCoreDecoder),
|
||||
std::sync::Arc::new(
|
||||
kb_decoder_spl_associated_token_account::SplAssociatedTokenAccountDecoder,
|
||||
),
|
||||
std::sync::Arc::new(kb_decoder_spl_elgamal_registry::SplElgamalRegistryDecoder),
|
||||
std::sync::Arc::new(kb_decoder_spl_memo::SplMemoDecoder),
|
||||
std::sync::Arc::new(kb_decoder_spl_token::SplTokenDecoder),
|
||||
std::sync::Arc::new(kb_decoder_spl_token_2022::SplToken2022Decoder),
|
||||
];
|
||||
}
|
||||
|
||||
pub(crate) fn build_demo_decode_replay_pipeline_request(
|
||||
request: crate::DemoDecodeReplayRequest,
|
||||
campaign_id: std::string::String,
|
||||
) -> std::result::Result<kb_pipeline::DecodeReplayRequest, std::string::String> {
|
||||
let states_result = processing_states(request.instruction_state.as_str());
|
||||
let states = match states_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let signatures = split_lines(request.signatures_text.as_deref());
|
||||
let instruction_paths = split_lines(request.instruction_paths_text.as_deref());
|
||||
let incomplete_signatures = request.instruction_state.trim() == "incomplete_signatures";
|
||||
let program_ids = match request.program_id {
|
||||
std::option::Option::Some(value) if !value.trim().is_empty() => {
|
||||
std::vec![value.trim().to_string()]
|
||||
},
|
||||
_ => std::vec::Vec::new(),
|
||||
};
|
||||
let selection_result = kb_store_core::DecodeSelectionFilter::new(
|
||||
signatures,
|
||||
states,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
program_ids,
|
||||
instruction_paths,
|
||||
incomplete_signatures,
|
||||
request.limit,
|
||||
);
|
||||
let selection = match selection_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let dispatch_policy = if request.all_compatible {
|
||||
kb_pipeline::DecodeDispatchPolicy::AllCompatible
|
||||
} else {
|
||||
kb_pipeline::DecodeDispatchPolicy::HighestPriority
|
||||
};
|
||||
let pipeline_request = kb_pipeline::DecodeReplayRequest {
|
||||
campaign_id,
|
||||
selection,
|
||||
decoder_names: request.decoder_names,
|
||||
dispatch_policy,
|
||||
max_concurrent_inputs: request.max_concurrent_inputs,
|
||||
force_replay: request.force_replay,
|
||||
force_replay_all_matching: request.force_replay_all_matching,
|
||||
materialize_after_decode: request.materialize_after_decode,
|
||||
};
|
||||
let validation_result = pipeline_request.validate();
|
||||
if let std::result::Result::Err(error) = validation_result {
|
||||
return std::result::Result::Err(error.to_string());
|
||||
}
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "build_request",
|
||||
campaign_id = %pipeline_request.campaign_id,
|
||||
signature_count = pipeline_request.selection.signatures.len(),
|
||||
signature_sample = ?text_sample(pipeline_request.selection.signatures.as_slice(), 5),
|
||||
processing_states = ?pipeline_request.selection.processing_states,
|
||||
program_ids = ?pipeline_request.selection.program_ids,
|
||||
instruction_paths = ?pipeline_request.selection.instruction_paths,
|
||||
incomplete_signatures = pipeline_request.selection.incomplete_signatures,
|
||||
limit = pipeline_request.selection.limit,
|
||||
decoder_names = ?pipeline_request.decoder_names,
|
||||
dispatch_policy = ?pipeline_request.dispatch_policy,
|
||||
max_concurrent_inputs = pipeline_request.max_concurrent_inputs,
|
||||
force_replay = pipeline_request.force_replay,
|
||||
force_replay_all_matching = pipeline_request.force_replay_all_matching,
|
||||
materialize_after_decode = pipeline_request.materialize_after_decode,
|
||||
"built normalized contextual decode replay request"
|
||||
);
|
||||
return std::result::Result::Ok(pipeline_request);
|
||||
}
|
||||
|
||||
pub(crate) fn optional_line_count(value: &std::option::Option<std::string::String>) -> usize {
|
||||
return match value {
|
||||
std::option::Option::Some(text) => {
|
||||
text.lines().filter(|line| return !line.trim().is_empty()).count()
|
||||
},
|
||||
std::option::Option::None => 0,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn text_sample(values: &[std::string::String], limit: usize) -> std::vec::Vec<&str> {
|
||||
return values.iter().take(limit).map(std::string::String::as_str).collect();
|
||||
}
|
||||
|
||||
fn processing_states(
|
||||
value: &str,
|
||||
) -> std::result::Result<
|
||||
std::vec::Vec<kb_store_core::CoreInstructionProcessingState>,
|
||||
std::string::String,
|
||||
> {
|
||||
return match value.trim() {
|
||||
"incomplete_signatures" | "actionable" => std::result::Result::Ok(std::vec![
|
||||
kb_store_core::CoreInstructionProcessingState::Pending,
|
||||
kb_store_core::CoreInstructionProcessingState::Failed,
|
||||
kb_store_core::CoreInstructionProcessingState::ReplayRequested,
|
||||
]),
|
||||
"pending" => std::result::Result::Ok(std::vec![
|
||||
kb_store_core::CoreInstructionProcessingState::Pending
|
||||
]),
|
||||
"failed" => std::result::Result::Ok(std::vec![
|
||||
kb_store_core::CoreInstructionProcessingState::Failed
|
||||
]),
|
||||
"replay_requested" => std::result::Result::Ok(std::vec![
|
||||
kb_store_core::CoreInstructionProcessingState::ReplayRequested
|
||||
]),
|
||||
"decoded" => std::result::Result::Ok(std::vec![
|
||||
kb_store_core::CoreInstructionProcessingState::Decoded
|
||||
]),
|
||||
"ignored" => std::result::Result::Ok(std::vec![
|
||||
kb_store_core::CoreInstructionProcessingState::Ignored
|
||||
]),
|
||||
"materialized" => std::result::Result::Ok(std::vec![
|
||||
kb_store_core::CoreInstructionProcessingState::Materialized
|
||||
]),
|
||||
_ => std::result::Result::Err("unsupported instruction processing state".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
fn split_lines(text: std::option::Option<&str>) -> std::vec::Vec<std::string::String> {
|
||||
let source = match text {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => "",
|
||||
};
|
||||
let mut unique = std::collections::BTreeSet::<std::string::String>::new();
|
||||
let mut output = std::vec::Vec::new();
|
||||
for line in source.lines() {
|
||||
let value = line.trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if unique.insert(value.to_string()) {
|
||||
output.push(value.to_string());
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
pub(crate) fn demo_decode_replay_summary_payload(
|
||||
summary: kb_pipeline::DecodeReplaySummary,
|
||||
) -> crate::DemoDecodeReplaySummaryPayload {
|
||||
return crate::DemoDecodeReplaySummaryPayload {
|
||||
campaign_id: summary.campaign_id,
|
||||
pipeline_version: summary.pipeline_version,
|
||||
selected: summary.selected,
|
||||
started: summary.started,
|
||||
completed: summary.completed,
|
||||
unmatched: summary.unmatched,
|
||||
not_started: summary.not_started,
|
||||
failed_inputs: summary.failed_inputs,
|
||||
cancelled: summary.cancelled,
|
||||
processors: summary
|
||||
.processors
|
||||
.into_iter()
|
||||
.map(|processor| {
|
||||
return crate::DemoDecodeProcessorSummaryPayload {
|
||||
processor_name: processor.processor_name,
|
||||
processor_version: processor.processor_version,
|
||||
dispatched: processor.dispatched,
|
||||
skipped: processor.skipped,
|
||||
decoded: processor.decoded,
|
||||
ignored: processor.ignored,
|
||||
unsupported: processor.unsupported,
|
||||
failed: processor.failed,
|
||||
materialized_outputs: processor.materialized_outputs,
|
||||
materialization_refused: processor.materialization_refused,
|
||||
};
|
||||
})
|
||||
.collect(),
|
||||
started_at: summary.started_at,
|
||||
finished_at: summary.finished_at,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn coverage_payload(
|
||||
value: kb_store_core::DecodeCoverageSummaryRow,
|
||||
) -> crate::DemoDecodeCoverageSummaryPayload {
|
||||
return crate::DemoDecodeCoverageSummaryPayload {
|
||||
processor_name: value.processor_name,
|
||||
processor_version: value.processor_version,
|
||||
program_id: value.program_id,
|
||||
surface_code: value.surface_code,
|
||||
entry_code: value.entry_code,
|
||||
declared_count: value.declared_count,
|
||||
observed_count: value.observed_count,
|
||||
recognized_count: value.recognized_count,
|
||||
decoded_count: value.decoded_count,
|
||||
materialized_count: value.materialized_count,
|
||||
error_count: value.error_count,
|
||||
unknown_count: value.unknown_count,
|
||||
successful_transaction_count: value.successful_transaction_count,
|
||||
failed_transaction_count: value.failed_transaction_count,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn annotation_payload(
|
||||
row: kb_store_core::MaterializedEventQueryRow,
|
||||
) -> std::result::Result<crate::DemoTransactionAnnotationRow, std::string::String> {
|
||||
if row.processor_name != "transaction_annotations"
|
||||
|| row.materialized_family != "transaction_annotation"
|
||||
{
|
||||
return std::result::Result::Err(
|
||||
"materialized row is not a transaction annotation".to_string(),
|
||||
);
|
||||
}
|
||||
let payload = &row.payload_json;
|
||||
let byte_length = match payload.get("payloadLengthBytes").and_then(serde_json::Value::as_u64) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
"transaction annotation payloadLengthBytes is absent".to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
let byte_length_result = u32::try_from(byte_length);
|
||||
let payload_length_bytes = match byte_length_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!(
|
||||
"transaction annotation payloadLengthBytes is not UI-safe: {error}"
|
||||
));
|
||||
},
|
||||
};
|
||||
let signers = match payload.get("signers") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
"transaction annotation signers are absent".to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
let verified_values = match signers.get("verified").and_then(serde_json::Value::as_array) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
"transaction annotation verified signers are invalid".to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
let mut verified_signers = std::vec::Vec::with_capacity(verified_values.len());
|
||||
for value in verified_values {
|
||||
match value.as_str() {
|
||||
std::option::Option::Some(signer) if !signer.trim().is_empty() => {
|
||||
verified_signers.push(signer.to_string());
|
||||
},
|
||||
_ => {
|
||||
return std::result::Result::Err(
|
||||
"transaction annotation verified signer is invalid".to_string(),
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
let instruction_path = match required_annotation_text(payload, "instructionPath") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let generation = match required_annotation_text(payload, "generation") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let program_id = match required_annotation_text(payload, "programId") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let text = match required_annotation_text(payload, "text") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let payload_sha256 = match required_annotation_text(payload, "payloadSha256") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let idempotence_key = match required_annotation_text(payload, "idempotenceKey") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::DemoTransactionAnnotationRow {
|
||||
processor_version: row.processor_version,
|
||||
decoder: format!("{}@{}", row.source_decoder_name, row.source_decoder_version),
|
||||
signature: row.signature,
|
||||
slot: row.slot.to_string(),
|
||||
instruction_path,
|
||||
generation,
|
||||
program_id,
|
||||
text,
|
||||
payload_length_bytes,
|
||||
payload_sha256,
|
||||
verified_signers,
|
||||
idempotence_key,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
fn required_annotation_text(
|
||||
payload: &serde_json::Value,
|
||||
field: &str,
|
||||
) -> std::result::Result<std::string::String, std::string::String> {
|
||||
return match payload.get(field).and_then(serde_json::Value::as_str) {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value.to_string()),
|
||||
std::option::Option::None => {
|
||||
std::result::Result::Err(format!("transaction annotation {field} is absent"))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn ata_observation(entry: &str) -> kb_decoder_api::DecodedObservation {
|
||||
return kb_decoder_api::DecodedObservation {
|
||||
event_key: format!("ata:{entry}:0"),
|
||||
event: kb_model::DecodedProtocolEvent {
|
||||
signature: kb_model::Signature("signature".to_string()),
|
||||
slot: kb_model::Slot(1),
|
||||
instruction_path: kb_model::InstructionPath("0".to_string()),
|
||||
program_id: kb_model::ProgramId(
|
||||
kb_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID.to_string(),
|
||||
),
|
||||
protocol_code: kb_model::ProtocolCode("spl_associated_token_account".to_string()),
|
||||
surface_code: kb_model::SurfaceCode("spl_associated_token_account".to_string()),
|
||||
event_code: kb_model::EventCode(format!("spl_associated_token_account.{entry}")),
|
||||
event_name: kb_model::EventName(entry.to_string()),
|
||||
event_family: kb_model::EventFamily::Lifecycle,
|
||||
source_kind: kb_model::EventSourceKind::Instruction,
|
||||
confidence: kb_model::DecoderConfidence::ManualExact,
|
||||
},
|
||||
payload_json: serde_json::json!({}),
|
||||
transaction_failed: false,
|
||||
transaction_error: std::option::Option::None,
|
||||
observation_committed: true,
|
||||
proof: kb_decoder_api::DecoderProof {
|
||||
kind: kb_decoder_api::DecoderProofKind::Manual,
|
||||
confidence: kb_model::DecoderConfidence::ManualExact,
|
||||
evidence: std::vec!["fixture".to_string()],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_memo_token_2022_elgamal_classic_token_and_ata_decoders_are_registered() {
|
||||
let decoders = crate::available_decoders();
|
||||
assert_eq!(decoders.len(), 6);
|
||||
let names = decoders
|
||||
.iter()
|
||||
.map(|decoder| return decoder.identity().name)
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(
|
||||
names,
|
||||
std::vec![
|
||||
"solana_native_classifier".to_string(),
|
||||
"spl_associated_token_account".to_string(),
|
||||
"spl_elgamal_registry".to_string(),
|
||||
"spl_memo".to_string(),
|
||||
"spl_token".to_string(),
|
||||
"spl_token_2022".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_materializer_registry_is_complete_for_current_instructional_surfaces() {
|
||||
let materializers = crate::available_materializers();
|
||||
assert_eq!(materializers.len(), 8);
|
||||
let names = materializers
|
||||
.iter()
|
||||
.map(|materializer| return materializer.identity().name)
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(
|
||||
names,
|
||||
std::vec![
|
||||
"solana_native_admin".to_string(),
|
||||
"solana_native_compliance_audit".to_string(),
|
||||
"fees".to_string(),
|
||||
"solana_native_lifecycle".to_string(),
|
||||
"solana_native_staking".to_string(),
|
||||
"spl_token_accounts".to_string(),
|
||||
"spl_token_risk".to_string(),
|
||||
"transaction_annotations".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ata_materializer_ownership_is_exact_in_the_runtime_registry() {
|
||||
for (entry, expected) in [
|
||||
("create", std::vec!["spl_token_accounts".to_string()]),
|
||||
(
|
||||
"recover_nested",
|
||||
std::vec!["spl_token_accounts".to_string(), "spl_token_risk".to_string(),],
|
||||
),
|
||||
] {
|
||||
let observation = ata_observation(entry);
|
||||
let owners = crate::available_materializers()
|
||||
.iter()
|
||||
.filter(|materializer| return materializer.accepts_observation(&observation))
|
||||
.map(|materializer| return materializer.identity().name)
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(owners, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_selection_is_trimmed_and_deduplicated() {
|
||||
let values = super::split_lines(std::option::Option::Some(" a \n\na\nb\n"));
|
||||
assert_eq!(values, std::vec!["a".to_string(), "b".to_string()]);
|
||||
}
|
||||
|
||||
fn request(
|
||||
signatures_text: std::option::Option<&str>,
|
||||
force_replay: bool,
|
||||
force_replay_all_matching: bool,
|
||||
materialize_after_decode: bool,
|
||||
) -> crate::DemoDecodeReplayRequest {
|
||||
return crate::DemoDecodeReplayRequest {
|
||||
signatures_text: signatures_text.map(|value| return value.to_string()),
|
||||
program_id: std::option::Option::None,
|
||||
instruction_state: "actionable".to_string(),
|
||||
instruction_paths_text: std::option::Option::None,
|
||||
decoder_names: std::vec!["solana_native_classifier".to_string()],
|
||||
limit: 10,
|
||||
max_concurrent_inputs: 2,
|
||||
all_compatible: false,
|
||||
force_replay,
|
||||
force_replay_all_matching,
|
||||
materialize_after_decode,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_replay_requires_signatures_or_all_matching_authorization() {
|
||||
let result = crate::build_demo_decode_replay_pipeline_request(
|
||||
request(std::option::Option::None, true, false, false),
|
||||
"decode-test".to_string(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_replay_all_matching_is_forwarded() {
|
||||
let result = crate::build_demo_decode_replay_pipeline_request(
|
||||
request(std::option::Option::None, true, true, false),
|
||||
"decode-test".to_string(),
|
||||
);
|
||||
let request = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("request mapping failed: {error}"),
|
||||
};
|
||||
assert!(request.force_replay_all_matching);
|
||||
assert!(request.selection.signatures.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_guard_clears_active_campaign_state() {
|
||||
let running = std::sync::atomic::AtomicBool::new(true);
|
||||
let campaign_id =
|
||||
std::sync::Mutex::new(std::option::Option::Some("decode-test".to_string()));
|
||||
{
|
||||
let _guard = crate::DemoDecodeReplayRunGuard {
|
||||
running: &running,
|
||||
campaign_id: &campaign_id,
|
||||
};
|
||||
}
|
||||
assert!(!running.load(std::sync::atomic::Ordering::Acquire));
|
||||
let lock_result = campaign_id.lock();
|
||||
let active_campaign_id = match lock_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => panic!("campaign id mutex must not be poisoned"),
|
||||
};
|
||||
assert!(active_campaign_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_active_campaign_sets_slot_without_exposing_guard() {
|
||||
let campaign_id = std::sync::Mutex::new(std::option::Option::None);
|
||||
let result = crate::register_active_campaign(&campaign_id, "decode-test");
|
||||
assert!(result.is_ok());
|
||||
let lock_result = campaign_id.lock();
|
||||
let active_campaign_id = match lock_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => panic!("campaign id mutex must not be poisoned"),
|
||||
};
|
||||
assert_eq!(active_campaign_id.as_deref(), std::option::Option::Some("decode-test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_signature_state_uses_actionable_states_and_expansion() {
|
||||
let mut request = request(std::option::Option::None, false, false, true);
|
||||
request.instruction_state = "incomplete_signatures".to_string();
|
||||
let result = crate::build_demo_decode_replay_pipeline_request(
|
||||
request,
|
||||
"decode-incomplete".to_string(),
|
||||
);
|
||||
let pipeline_request = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("request mapping failed: {error}"),
|
||||
};
|
||||
assert!(pipeline_request.selection.incomplete_signatures);
|
||||
assert_eq!(pipeline_request.selection.processing_states.len(), 3);
|
||||
assert!(!pipeline_request.force_replay);
|
||||
assert!(pipeline_request.materialize_after_decode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actionable_state_contains_pending_failed_and_replay_requested() {
|
||||
let result = super::processing_states("actionable");
|
||||
let states = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("state mapping failed: {error}"),
|
||||
};
|
||||
assert_eq!(states.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_annotation_row_is_mapped_to_ui_safe_contract() {
|
||||
let row = kb_store_core::MaterializedEventQueryRow {
|
||||
processor_name: "transaction_annotations".to_string(),
|
||||
processor_version: "0.4.3".to_string(),
|
||||
input_key: "input".to_string(),
|
||||
output_key: "output".to_string(),
|
||||
source_event_key: "memo:0".to_string(),
|
||||
source_decoder_name: "spl_memo".to_string(),
|
||||
source_decoder_version: "0.4.3".to_string(),
|
||||
signature: "signature".to_string(),
|
||||
slot: u64::MAX,
|
||||
materialized_family: "transaction_annotation".to_string(),
|
||||
payload_json: serde_json::json!({
|
||||
"instructionPath": "1/0",
|
||||
"generation": "v4",
|
||||
"programId": kb_program_ids::SPL_MEMO_V4_PROGRAM_ID,
|
||||
"text": "annotation",
|
||||
"payloadLengthBytes": 10,
|
||||
"payloadSha256": "11",
|
||||
"signers": {"verified": ["signer"]},
|
||||
"idempotenceKey": "stable"
|
||||
}),
|
||||
created_at: "created".to_string(),
|
||||
updated_at: "updated".to_string(),
|
||||
};
|
||||
let result = crate::annotation_payload(row);
|
||||
let mapped = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("annotation mapping failed: {error}"),
|
||||
};
|
||||
assert_eq!(mapped.slot, u64::MAX.to_string());
|
||||
assert_eq!(mapped.text, "annotation");
|
||||
assert_eq!(mapped.verified_signers, std::vec!["signer".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_annotation_payload_fails_closed() {
|
||||
let row = kb_store_core::MaterializedEventQueryRow {
|
||||
processor_name: "transaction_annotations".to_string(),
|
||||
processor_version: "0.4.3".to_string(),
|
||||
input_key: "input".to_string(),
|
||||
output_key: "output".to_string(),
|
||||
source_event_key: "memo:0".to_string(),
|
||||
source_decoder_name: "spl_memo".to_string(),
|
||||
source_decoder_version: "0.4.3".to_string(),
|
||||
signature: "signature".to_string(),
|
||||
slot: 1,
|
||||
materialized_family: "transaction_annotation".to_string(),
|
||||
payload_json: serde_json::json!({}),
|
||||
created_at: "created".to_string(),
|
||||
updated_at: "updated".to_string(),
|
||||
};
|
||||
assert!(crate::annotation_payload(row).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
// file: kb_app_demo/src/demo_execution_solana_core.rs
|
||||
// version: 5
|
||||
|
||||
//! Tauri adapter for bounded Solana Core execution on Devnet.
|
||||
|
||||
use tauri::Emitter; // rust-rules: trait-import
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// One Devnet execution profile exposed to the demo window.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreProfileOption.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSolanaCoreProfileOption {
|
||||
/// Stable profile name.
|
||||
pub(crate) name: std::string::String,
|
||||
/// Non-secret temporary wallet alias.
|
||||
pub(crate) wallet_alias: std::string::String,
|
||||
/// Maximum Devnet spend allowed by the profile.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) max_spend_lamports: u64,
|
||||
/// Maximum faucet request allowed by the profile.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) max_airdrop_lamports: u64,
|
||||
/// Whether signed Devnet submission is enabled.
|
||||
pub(crate) send_enabled: bool,
|
||||
/// Whether explicit operator confirmation is required.
|
||||
pub(crate) require_operator_confirmation: bool,
|
||||
}
|
||||
|
||||
/// Initial options shown by the Solana Core execution demo.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreOptionsPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSolanaCoreOptionsPayload {
|
||||
/// Devnet profiles compatible with the execution laboratory.
|
||||
pub(crate) profiles: std::vec::Vec<crate::DemoExecutionSolanaCoreProfileOption>,
|
||||
/// Suggested profile name.
|
||||
pub(crate) default_profile_name: std::option::Option<std::string::String>,
|
||||
/// Default transfer amount suitable for a new zero-data account on Devnet.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) default_transfer_lamports: u64,
|
||||
/// Whether one execution is currently running.
|
||||
pub(crate) running: bool,
|
||||
}
|
||||
|
||||
/// Request sent by the execution demo.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSolanaCoreRequest {
|
||||
/// Selected Devnet profile.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Recipient public key.
|
||||
pub(crate) recipient: std::string::String,
|
||||
/// Lamports transferred by the System instruction.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) lamports: u64,
|
||||
/// Optional bounded faucet request when the source wallet is underfunded.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) airdrop_lamports: u64,
|
||||
/// Whether the transaction should be signed and submitted after simulation.
|
||||
pub(crate) submit: bool,
|
||||
/// Explicit operator confirmation for signed submission.
|
||||
pub(crate) operator_confirmed: bool,
|
||||
/// Whether post-execution core/decode outputs should be force-replayed.
|
||||
pub(crate) force_post_validation_replay: bool,
|
||||
/// Whether compatible materializers should run after decode replay.
|
||||
pub(crate) materialize_after_decode: bool,
|
||||
}
|
||||
|
||||
/// Request sent by the Memo v4 Devnet execution panel.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionMemoRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMemoRequest {
|
||||
/// Selected Devnet profile.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Exact UTF-8 Memo payload.
|
||||
pub(crate) message: std::string::String,
|
||||
/// Whether the wallet is supplied as a readonly Memo signer account.
|
||||
pub(crate) include_wallet_as_memo_signer: bool,
|
||||
/// Whether the transaction should be signed and submitted after simulation.
|
||||
pub(crate) submit: bool,
|
||||
/// Explicit operator confirmation for signed submission.
|
||||
pub(crate) operator_confirmed: bool,
|
||||
/// Whether post-execution core and first decode outputs should be force-replayed.
|
||||
pub(crate) force_post_validation_replay: bool,
|
||||
}
|
||||
|
||||
/// Public key generated for a disposable Devnet recipient.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreGeneratedRecipientPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSolanaCoreGeneratedRecipientPayload {
|
||||
/// Base58 public key. The private key is not persisted or exposed.
|
||||
pub(crate) public_key: std::string::String,
|
||||
}
|
||||
|
||||
/// Progress event emitted during execution and post-validation.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreProgressPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSolanaCoreProgressPayload {
|
||||
/// RFC 3339 timestamp.
|
||||
pub(crate) timestamp: std::string::String,
|
||||
/// Stable severity code.
|
||||
pub(crate) level: std::string::String,
|
||||
/// Stable stage code.
|
||||
pub(crate) stage: std::string::String,
|
||||
/// Human-readable message.
|
||||
pub(crate) message: std::string::String,
|
||||
/// Transaction signature when already available.
|
||||
pub(crate) signature: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
/// UI-safe result of one execution orchestration.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionSolanaCoreSummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSolanaCoreSummaryPayload {
|
||||
/// Profile used by the execution.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Classified cluster code.
|
||||
pub(crate) cluster: std::string::String,
|
||||
/// Genesis hash returned by the endpoint.
|
||||
pub(crate) genesis_hash: std::string::String,
|
||||
/// Source wallet public key.
|
||||
pub(crate) wallet_public_key: std::string::String,
|
||||
/// Recipient public key.
|
||||
pub(crate) recipient: std::string::String,
|
||||
/// Whether the recipient account existed before execution.
|
||||
pub(crate) recipient_existed_before: bool,
|
||||
/// Recipient balance before execution when present.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) recipient_balance_before_lamports: std::option::Option<u64>,
|
||||
/// Rent-exempt minimum required for a new zero-data account.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) recipient_minimum_balance_lamports: u64,
|
||||
/// Source balance before optional funding.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) balance_before_lamports: u64,
|
||||
/// Source balance after optional funding.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) balance_after_funding_lamports: u64,
|
||||
/// Estimated transaction fee.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) fee_lamports: std::option::Option<u64>,
|
||||
/// Whether exact simulation succeeded.
|
||||
pub(crate) simulation_success: bool,
|
||||
/// Runtime simulation error when present.
|
||||
pub(crate) simulation_error: std::option::Option<std::string::String>,
|
||||
/// Runtime simulation logs.
|
||||
pub(crate) simulation_logs: std::vec::Vec<std::string::String>,
|
||||
/// Faucet signature when funding was requested.
|
||||
pub(crate) airdrop_signature: std::option::Option<std::string::String>,
|
||||
/// Submitted transaction signature.
|
||||
pub(crate) transaction_signature: std::option::Option<std::string::String>,
|
||||
/// Terminal confirmation status.
|
||||
pub(crate) confirmation_status: std::option::Option<std::string::String>,
|
||||
/// Canonical rows inserted after submission.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) canonical_inserted: std::option::Option<u64>,
|
||||
/// Core transactions extracted after submission.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) core_extracted: std::option::Option<u64>,
|
||||
/// Contextual decode inputs completed after submission.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) decode_completed: std::option::Option<u64>,
|
||||
/// Contextual decode input failures after submission.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) decode_failed_inputs: std::option::Option<u64>,
|
||||
/// Pretty JSON representation of the exact execution plan.
|
||||
pub(crate) plan_json: std::string::String,
|
||||
/// Pretty JSON representation of the simulation result.
|
||||
pub(crate) simulation_json: std::string::String,
|
||||
/// Pretty JSON representation of confirmation and post-validation diagnostics.
|
||||
pub(crate) diagnostics_json: std::string::String,
|
||||
}
|
||||
|
||||
/// UI-safe result of one Memo v4 Devnet execution orchestration.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_execution_solana_core/DemoExecutionMemoSummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMemoSummaryPayload {
|
||||
/// Profile used by the execution.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Classified cluster code.
|
||||
pub(crate) cluster: std::string::String,
|
||||
/// Genesis hash returned by the endpoint.
|
||||
pub(crate) genesis_hash: std::string::String,
|
||||
/// Source wallet public key.
|
||||
pub(crate) wallet_public_key: std::string::String,
|
||||
/// Exact wallet balance rendered without JSON integer precision loss.
|
||||
pub(crate) balance_lamports: std::string::String,
|
||||
/// Estimated transaction fee rendered without JSON integer precision loss.
|
||||
pub(crate) fee_lamports: std::option::Option<std::string::String>,
|
||||
/// Whether exact simulation succeeded.
|
||||
pub(crate) simulation_success: bool,
|
||||
/// Runtime simulation error when present.
|
||||
pub(crate) simulation_error: std::option::Option<std::string::String>,
|
||||
/// Submitted transaction signature.
|
||||
pub(crate) transaction_signature: std::option::Option<std::string::String>,
|
||||
/// Terminal confirmation status.
|
||||
pub(crate) confirmation_status: std::option::Option<std::string::String>,
|
||||
/// Whether canonical hydration was validated.
|
||||
pub(crate) canonical_inserted: bool,
|
||||
/// Whether core extraction was validated.
|
||||
pub(crate) core_extracted: bool,
|
||||
/// Whether Memo decode replay was validated.
|
||||
pub(crate) decode_replayed: bool,
|
||||
/// Whether the committed transaction annotation was validated.
|
||||
pub(crate) materialized: bool,
|
||||
/// Number of exact annotation rows returned by the bounded query.
|
||||
pub(crate) annotation_count: u32,
|
||||
/// Whether the second replay produced no failures or new materialized output.
|
||||
pub(crate) idempotence_validated: bool,
|
||||
/// Pretty JSON representation of the exact execution plan.
|
||||
pub(crate) plan_json: std::string::String,
|
||||
/// Pretty JSON representation of the simulation result.
|
||||
pub(crate) simulation_json: std::string::String,
|
||||
/// Pretty JSON representation of annotations and post-validation diagnostics.
|
||||
pub(crate) diagnostics_json: std::string::String,
|
||||
}
|
||||
|
||||
pub(crate) struct DemoExecutionSolanaCoreObserver<'a> {
|
||||
pub(crate) app_handle: tauri::AppHandle,
|
||||
pub(crate) cancel_requested: &'a std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl crate::DemoExecutionSolanaCoreObserver<'_> {
|
||||
fn emit(
|
||||
&self,
|
||||
timestamp: std::string::String,
|
||||
level: &str,
|
||||
stage: &str,
|
||||
message: std::string::String,
|
||||
signature: std::option::Option<std::string::String>,
|
||||
) {
|
||||
let payload = crate::DemoExecutionSolanaCoreProgressPayload {
|
||||
timestamp,
|
||||
level: level.to_string(),
|
||||
stage: stage.to_string(),
|
||||
message,
|
||||
signature,
|
||||
};
|
||||
let emit_result = self.app_handle.emit_to(
|
||||
"demo_execution_solana_core",
|
||||
"demo-execution-solana-core-progress",
|
||||
payload,
|
||||
);
|
||||
if let std::result::Result::Err(error) = emit_result {
|
||||
tracing::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "emit_execution_progress",
|
||||
error = %error,
|
||||
"cannot emit Solana Core execution progress"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cancelled(&self) -> bool {
|
||||
return self.cancel_requested.load(std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl kb_pipeline::BackfillObserver for crate::DemoExecutionSolanaCoreObserver<'_> {
|
||||
fn on_progress(&self, event: &kb_pipeline::BackfillProgressEvent) {
|
||||
self.emit(
|
||||
event.timestamp.clone(),
|
||||
event.level.code(),
|
||||
"canonical_insert",
|
||||
event.message.clone(),
|
||||
std::option::Option::None,
|
||||
);
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
return self.cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
impl kb_pipeline::CoreExtractionObserver for crate::DemoExecutionSolanaCoreObserver<'_> {
|
||||
fn on_progress(&self, event: &kb_pipeline::CoreExtractionProgressEvent) {
|
||||
self.emit(
|
||||
event.timestamp.clone(),
|
||||
event.level.code(),
|
||||
"core_extraction",
|
||||
event.message.clone(),
|
||||
std::option::Option::None,
|
||||
);
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
return self.cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
impl kb_pipeline::DecodeReplayObserver for crate::DemoExecutionSolanaCoreObserver<'_> {
|
||||
fn on_progress(&self, event: &kb_pipeline::DecodeReplayProgressEvent) {
|
||||
self.emit(
|
||||
event.timestamp.clone(),
|
||||
event.level.code(),
|
||||
"decode_replay",
|
||||
event.message.clone(),
|
||||
std::option::Option::None,
|
||||
);
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
return self.cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
impl kb_pipeline::SolanaExecutionObserver for crate::DemoExecutionSolanaCoreObserver<'_> {
|
||||
fn on_execution_progress(&self, event: &kb_pipeline::SolanaExecutionProgressEvent) {
|
||||
self.emit(
|
||||
event.timestamp.clone(),
|
||||
event.level.code(),
|
||||
event.stage.as_str(),
|
||||
event.message.clone(),
|
||||
event.signature.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
fn is_execution_cancelled(&self) -> bool {
|
||||
return self.cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct DemoExecutionSolanaCoreRunGuard<'a> {
|
||||
pub(crate) running: &'a std::sync::atomic::AtomicBool,
|
||||
}
|
||||
impl std::ops::Drop for crate::DemoExecutionSolanaCoreRunGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn devnet_profile_options(
|
||||
config: &kb_config::AppConfig,
|
||||
) -> std::vec::Vec<crate::DemoExecutionSolanaCoreProfileOption> {
|
||||
let mut output = std::vec::Vec::new();
|
||||
for profile in &config.profiles {
|
||||
if profile.wallet.cluster != "devnet"
|
||||
|| !profile.wallet.temporary_wallet_enabled
|
||||
|| !profile.wallet.temporary_wallet_persist
|
||||
{
|
||||
continue;
|
||||
}
|
||||
output.push(crate::DemoExecutionSolanaCoreProfileOption {
|
||||
name: profile.name.clone(),
|
||||
wallet_alias: profile.wallet.temporary_wallet_alias.clone(),
|
||||
max_spend_lamports: profile.execution.devnet_max_spend_lamports,
|
||||
max_airdrop_lamports: profile.execution.devnet_airdrop_max_lamports,
|
||||
send_enabled: profile.wallet.devnet_send_enabled,
|
||||
require_operator_confirmation: profile.execution.require_operator_confirmation,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
pub(crate) fn select_devnet_profile(
|
||||
config: &kb_config::AppConfig,
|
||||
profile_name: &str,
|
||||
) -> std::result::Result<kb_config::ProfileConfig, std::string::String> {
|
||||
for profile in &config.profiles {
|
||||
if profile.name == profile_name
|
||||
&& profile.wallet.cluster == "devnet"
|
||||
&& profile.wallet.temporary_wallet_enabled
|
||||
&& profile.wallet.temporary_wallet_persist
|
||||
{
|
||||
return std::result::Result::Ok(profile.clone());
|
||||
}
|
||||
}
|
||||
return std::result::Result::Err(format!(
|
||||
"Devnet execution profile '{profile_name}' is unavailable"
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn demo_execution_solana_core_summary_payload(
|
||||
summary: kb_pipeline::DevnetSystemTransferSummary,
|
||||
) -> crate::DemoExecutionSolanaCoreSummaryPayload {
|
||||
let plan_json = crate::pretty_json(&summary.plan);
|
||||
let simulation_json = crate::pretty_json(&summary.simulation);
|
||||
let transaction_signature =
|
||||
summary.send_result.as_ref().map(|value| return value.signature.0.clone());
|
||||
let confirmation_status = summary.confirmation.as_ref().map(|value| {
|
||||
return crate::confirmation_status_code(value.status);
|
||||
});
|
||||
let canonical_inserted = summary.backfill.as_ref().map(|value| return value.canonical_inserted);
|
||||
let core_extracted = summary.core_extraction.as_ref().map(|value| return value.extracted);
|
||||
let decode_completed = summary.decode_replay.as_ref().map(|value| return value.completed);
|
||||
let decode_failed_inputs =
|
||||
summary.decode_replay.as_ref().map(|value| return value.failed_inputs);
|
||||
let diagnostics_value = serde_json::json!({
|
||||
"airdropConfirmation": summary.airdrop_confirmation.as_ref(),
|
||||
"confirmation": summary.confirmation.as_ref(),
|
||||
"postExecution": summary.post_execution.as_ref(),
|
||||
"backfill": summary.backfill.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"canonicalInserted": value.canonical_inserted,
|
||||
"canonicalSkipped": value.canonical_skipped,
|
||||
"existingSkipped": value.existing_skipped,
|
||||
"missing": value.missing,
|
||||
"failed": value.failed
|
||||
});
|
||||
}),
|
||||
"coreExtraction": summary.core_extraction.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"selected": value.selected,
|
||||
"extracted": value.extracted,
|
||||
"skipped": value.skipped,
|
||||
"failed": value.failed
|
||||
});
|
||||
}),
|
||||
"decodeReplay": summary.decode_replay.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"campaignId": value.campaign_id,
|
||||
"selected": value.selected,
|
||||
"completed": value.completed,
|
||||
"unmatched": value.unmatched,
|
||||
"failedInputs": value.failed_inputs,
|
||||
"cancelled": value.cancelled
|
||||
});
|
||||
})
|
||||
});
|
||||
let diagnostics_json = crate::pretty_json(&diagnostics_value);
|
||||
let cluster = crate::execution_cluster_code(summary.cluster);
|
||||
return crate::DemoExecutionSolanaCoreSummaryPayload {
|
||||
profile_name: summary.profile_name,
|
||||
cluster,
|
||||
genesis_hash: summary.genesis_hash,
|
||||
wallet_public_key: summary.wallet.public_key,
|
||||
recipient: summary.recipient.0,
|
||||
recipient_existed_before: summary.recipient_existed_before,
|
||||
recipient_balance_before_lamports: summary.recipient_balance_before_lamports,
|
||||
recipient_minimum_balance_lamports: summary.recipient_minimum_balance_lamports,
|
||||
balance_before_lamports: summary.balance_before_lamports,
|
||||
balance_after_funding_lamports: summary.balance_after_funding_lamports,
|
||||
fee_lamports: summary.fee.fee_lamports,
|
||||
simulation_success: summary.simulation.success,
|
||||
simulation_error: summary.simulation.error,
|
||||
simulation_logs: summary.simulation.logs,
|
||||
airdrop_signature: summary.airdrop_signature.map(|value| return value.0),
|
||||
transaction_signature,
|
||||
confirmation_status,
|
||||
canonical_inserted,
|
||||
core_extracted,
|
||||
decode_completed,
|
||||
decode_failed_inputs,
|
||||
plan_json,
|
||||
simulation_json,
|
||||
diagnostics_json,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn demo_execution_solana_core_memo_summary_payload(
|
||||
summary: kb_pipeline::DevnetMemoExecutionSummary,
|
||||
) -> crate::DemoExecutionMemoSummaryPayload {
|
||||
let plan_json = crate::pretty_json(&summary.plan);
|
||||
let simulation_json = crate::pretty_json(&summary.simulation);
|
||||
let transaction_signature =
|
||||
summary.send_result.as_ref().map(|value| return value.signature.0.clone());
|
||||
let confirmation_status = summary.confirmation.as_ref().map(|value| {
|
||||
return crate::confirmation_status_code(value.status);
|
||||
});
|
||||
let canonical_inserted = summary
|
||||
.post_execution
|
||||
.as_ref()
|
||||
.is_some_and(|value| return value.canonical_inserted);
|
||||
let core_extracted =
|
||||
summary.post_execution.as_ref().is_some_and(|value| return value.core_extracted);
|
||||
let decode_replayed = summary
|
||||
.post_execution
|
||||
.as_ref()
|
||||
.is_some_and(|value| return value.decode_replayed);
|
||||
let materialized =
|
||||
summary.post_execution.as_ref().is_some_and(|value| return value.materialized);
|
||||
let idempotence_validated = summary.idempotence_replay.as_ref().is_some_and(|replay| {
|
||||
return replay.failed_inputs == 0
|
||||
&& !replay.cancelled
|
||||
&& replay.processors.iter().all(|processor| {
|
||||
return processor.failed == 0
|
||||
&& processor.materialized_outputs == 0
|
||||
&& processor.materialization_refused == 0;
|
||||
})
|
||||
&& replay.processors.iter().map(|processor| return processor.skipped).sum::<u64>()
|
||||
>= 1;
|
||||
});
|
||||
let annotation_count = match u32::try_from(summary.annotations.len()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => u32::MAX,
|
||||
};
|
||||
let diagnostics_value = serde_json::json!({
|
||||
"confirmation": summary.confirmation.as_ref(),
|
||||
"postExecution": summary.post_execution.as_ref(),
|
||||
"backfill": summary.backfill.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"canonicalInserted": value.canonical_inserted,
|
||||
"canonicalSkipped": value.canonical_skipped,
|
||||
"existingSkipped": value.existing_skipped,
|
||||
"missing": value.missing,
|
||||
"failed": value.failed
|
||||
});
|
||||
}),
|
||||
"coreExtraction": summary.core_extraction.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"selected": value.selected,
|
||||
"extracted": value.extracted,
|
||||
"skipped": value.skipped,
|
||||
"failed": value.failed
|
||||
});
|
||||
}),
|
||||
"decodeReplay": summary.decode_replay.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"campaignId": value.campaign_id,
|
||||
"selected": value.selected,
|
||||
"completed": value.completed,
|
||||
"failedInputs": value.failed_inputs,
|
||||
"processors": value.processors.iter().map(|processor| {
|
||||
return serde_json::json!({
|
||||
"name": processor.processor_name,
|
||||
"version": processor.processor_version,
|
||||
"decoded": processor.decoded,
|
||||
"skipped": processor.skipped,
|
||||
"failed": processor.failed,
|
||||
"materializedOutputs": processor.materialized_outputs,
|
||||
"materializationRefused": processor.materialization_refused
|
||||
});
|
||||
}).collect::<std::vec::Vec<_>>()
|
||||
});
|
||||
}),
|
||||
"idempotenceReplay": summary.idempotence_replay.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"campaignId": value.campaign_id,
|
||||
"selected": value.selected,
|
||||
"completed": value.completed,
|
||||
"failedInputs": value.failed_inputs,
|
||||
"processors": value.processors.iter().map(|processor| {
|
||||
return serde_json::json!({
|
||||
"name": processor.processor_name,
|
||||
"version": processor.processor_version,
|
||||
"decoded": processor.decoded,
|
||||
"skipped": processor.skipped,
|
||||
"failed": processor.failed,
|
||||
"materializedOutputs": processor.materialized_outputs,
|
||||
"materializationRefused": processor.materialization_refused
|
||||
});
|
||||
}).collect::<std::vec::Vec<_>>()
|
||||
});
|
||||
}),
|
||||
"annotations": summary.annotations
|
||||
});
|
||||
let diagnostics_json = crate::pretty_json(&diagnostics_value);
|
||||
return crate::DemoExecutionMemoSummaryPayload {
|
||||
profile_name: summary.profile_name,
|
||||
cluster: crate::execution_cluster_code(summary.cluster),
|
||||
genesis_hash: summary.genesis_hash,
|
||||
wallet_public_key: summary.wallet.public_key,
|
||||
balance_lamports: summary.balance_lamports.to_string(),
|
||||
fee_lamports: summary.fee.fee_lamports.map(|value| return value.to_string()),
|
||||
simulation_success: summary.simulation.success,
|
||||
simulation_error: summary.simulation.error,
|
||||
transaction_signature,
|
||||
confirmation_status,
|
||||
canonical_inserted,
|
||||
core_extracted,
|
||||
decode_replayed,
|
||||
materialized,
|
||||
annotation_count,
|
||||
idempotence_validated,
|
||||
plan_json,
|
||||
simulation_json,
|
||||
diagnostics_json,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn execution_cluster_code(
|
||||
cluster: kb_execution_api::ExecutionCluster,
|
||||
) -> std::string::String {
|
||||
return match cluster {
|
||||
kb_execution_api::ExecutionCluster::Localnet => "localnet".to_string(),
|
||||
kb_execution_api::ExecutionCluster::Devnet => "devnet".to_string(),
|
||||
kb_execution_api::ExecutionCluster::Testnet => "testnet".to_string(),
|
||||
kb_execution_api::ExecutionCluster::Mainnet => "mainnet".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn confirmation_status_code(
|
||||
status: kb_execution_api::ExecutionConfirmationStatus,
|
||||
) -> std::string::String {
|
||||
return match status {
|
||||
kb_execution_api::ExecutionConfirmationStatus::Processed => "processed".to_string(),
|
||||
kb_execution_api::ExecutionConfirmationStatus::Confirmed => "confirmed".to_string(),
|
||||
kb_execution_api::ExecutionConfirmationStatus::Finalized => "finalized".to_string(),
|
||||
kb_execution_api::ExecutionConfirmationStatus::Failed => "failed".to_string(),
|
||||
kb_execution_api::ExecutionConfirmationStatus::Expired => "expired".to_string(),
|
||||
kb_execution_api::ExecutionConfirmationStatus::TimedOut => "timed_out".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn pretty_json<T>(value: &T) -> std::string::String
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
return match serde_json::to_string_pretty(value) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
serde_json::json!({"serializationError": error.to_string()}).to_string()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn example_config_exposes_one_devnet_execution_profile() {
|
||||
let config =
|
||||
match kb_config::parse_config_json(include_str!("../../config/example.config.json")) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("example config parse failed: {error}"),
|
||||
};
|
||||
let profiles = crate::devnet_profile_options(&config);
|
||||
assert_eq!(profiles.len(), 1);
|
||||
assert_eq!(profiles[0].name, "local_devnet");
|
||||
assert!(profiles[0].send_enabled);
|
||||
assert!(profiles[0].max_spend_lamports >= 1_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_selection_rejects_mainnet_profiles() {
|
||||
let config =
|
||||
match kb_config::parse_config_json(include_str!("../../config/example.config.json")) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("example config parse failed: {error}"),
|
||||
};
|
||||
assert!(crate::select_devnet_profile(&config, "local_devnet").is_ok());
|
||||
assert!(crate::select_devnet_profile(&config, "mainnet").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// file: kb_app_demo/src/demo_execution_spl.rs
|
||||
// version: 3
|
||||
|
||||
//! Dedicated Tauri window for bounded SPL execution on Devnet.
|
||||
|
||||
/// Frontend payload for one independent Devnet SPL validation scenario.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_execution_spl/DevnetSplValidationScenarioPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DevnetSplValidationScenarioPayload {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Operator-visible label.
|
||||
pub label: std::string::String,
|
||||
/// Stable family code.
|
||||
pub family: std::string::String,
|
||||
/// Stable executor operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Current implementation status.
|
||||
pub implementation_status: std::string::String,
|
||||
/// Whether the scenario requires proof material.
|
||||
pub proof_required: bool,
|
||||
/// Ordered required fixture variables.
|
||||
pub required_fixture_variables: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn memo_summary_keeps_lamports_as_json_strings() {
|
||||
let payload = crate::DemoExecutionMemoSummaryPayload {
|
||||
profile_name: "local_devnet".to_string(),
|
||||
cluster: "devnet".to_string(),
|
||||
genesis_hash: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1".to_string(),
|
||||
wallet_public_key: kb_program_ids::SYSTEM_PROGRAM_ID.to_string(),
|
||||
balance_lamports: u64::MAX.to_string(),
|
||||
fee_lamports: std::option::Option::Some(u64::MAX.to_string()),
|
||||
simulation_success: true,
|
||||
simulation_error: std::option::Option::None,
|
||||
transaction_signature: std::option::Option::None,
|
||||
confirmation_status: std::option::Option::None,
|
||||
canonical_inserted: false,
|
||||
core_extracted: false,
|
||||
decode_replayed: false,
|
||||
materialized: false,
|
||||
annotation_count: 0,
|
||||
idempotence_validated: false,
|
||||
plan_json: "{}".to_string(),
|
||||
simulation_json: "{}".to_string(),
|
||||
diagnostics_json: "{}".to_string(),
|
||||
};
|
||||
let json = match serde_json::to_value(payload) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
assert_eq!(
|
||||
std::option::Option::Some(error.to_string()),
|
||||
std::option::Option::None,
|
||||
"Memo summary serialization failed"
|
||||
);
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert!(json["balanceLamports"].is_string());
|
||||
assert!(json["feeLamports"].is_string());
|
||||
assert_eq!(json["balanceLamports"], u64::MAX.to_string());
|
||||
}
|
||||
}
|
||||
361
migration/khadhroony-bot2-reference/kb_app_demo/src/demo_http.rs
Normal file
361
migration/khadhroony-bot2-reference/kb_app_demo/src/demo_http.rs
Normal file
@@ -0,0 +1,361 @@
|
||||
// file: kb_app_demo/src/demo_http.rs
|
||||
// version: 8
|
||||
|
||||
//! HTTP JSON-RPC demo commands.
|
||||
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// One selectable role shown by the HTTP demo.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_http/DemoHttpRoleOption.ts"
|
||||
)]
|
||||
pub(crate) struct DemoHttpRoleOption {
|
||||
/// Endpoint role code.
|
||||
pub(crate) role: std::string::String,
|
||||
/// Request kinds accepted by this role.
|
||||
pub(crate) request_kinds: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// One selectable HTTP JSON-RPC method shown by the HTTP demo.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_http/DemoHttpMethodOption.ts"
|
||||
)]
|
||||
pub(crate) struct DemoHttpMethodOption {
|
||||
/// JSON-RPC method name.
|
||||
pub(crate) method: std::string::String,
|
||||
/// Derived request kind used for endpoint routing.
|
||||
pub(crate) request_kind: std::string::String,
|
||||
/// Human-readable method label.
|
||||
pub(crate) label: std::string::String,
|
||||
/// Whether this method needs the first argument field.
|
||||
pub(crate) requires_first_arg: bool,
|
||||
/// Whether this method supports an optional configuration object.
|
||||
pub(crate) supports_config_json: bool,
|
||||
}
|
||||
|
||||
/// HTTP demo options derived from configuration and local method presets.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_http/DemoHttpOptionsPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoHttpOptionsPayload {
|
||||
/// Selectable roles.
|
||||
pub(crate) roles: std::vec::Vec<crate::DemoHttpRoleOption>,
|
||||
/// Selectable methods.
|
||||
pub(crate) methods: std::vec::Vec<crate::DemoHttpMethodOption>,
|
||||
}
|
||||
|
||||
/// Request payload for one HTTP JSON-RPC demo execution.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_http/DemoHttpRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoHttpRequest {
|
||||
/// Required endpoint role used by the HTTP pool.
|
||||
pub(crate) role: std::string::String,
|
||||
/// JSON-RPC method name.
|
||||
pub(crate) method: std::string::String,
|
||||
/// Optional first argument string.
|
||||
pub(crate) first_arg: std::option::Option<std::string::String>,
|
||||
/// Optional JSON configuration string appended after the first argument.
|
||||
pub(crate) config_json: std::option::Option<std::string::String>,
|
||||
/// Optional raw JSON-RPC params array. When present, it overrides firstArg and configJson.
|
||||
pub(crate) params_json: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
/// Response payload for one HTTP JSON-RPC demo execution.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_http/DemoHttpExecutionPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoHttpExecutionPayload {
|
||||
/// Selected endpoint name.
|
||||
pub(crate) endpoint_name: std::string::String,
|
||||
/// Selected provider name.
|
||||
pub(crate) provider: std::string::String,
|
||||
/// Selected endpoint URL.
|
||||
pub(crate) endpoint_url: std::string::String,
|
||||
/// Required role used by the selection.
|
||||
pub(crate) role: std::string::String,
|
||||
/// JSON-RPC method name.
|
||||
pub(crate) method: std::string::String,
|
||||
/// Derived request kind.
|
||||
pub(crate) request_kind: std::string::String,
|
||||
/// Classified method family.
|
||||
pub(crate) method_class: std::string::String,
|
||||
/// Pretty JSON response text.
|
||||
pub(crate) response_json: std::string::String,
|
||||
}
|
||||
|
||||
pub(crate) async fn demo_http_execute_request_inner(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
request: crate::DemoHttpRequest,
|
||||
) -> std::result::Result<crate::DemoHttpExecutionPayload, std::string::String> {
|
||||
let role = request.role.trim().to_string();
|
||||
if role.is_empty() {
|
||||
return std::result::Result::Err("demo HTTP role must not be empty".to_string());
|
||||
}
|
||||
let method = request.method.trim().to_string();
|
||||
if method.is_empty() {
|
||||
return std::result::Result::Err("demo HTTP method must not be empty".to_string());
|
||||
}
|
||||
let params_json_value = match parse_optional_params_json(request.params_json) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let config_json_value = match parse_optional_json(request.config_json) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let params = match build_demo_http_params(
|
||||
&method,
|
||||
request.first_arg.as_deref(),
|
||||
config_json_value,
|
||||
params_json_value,
|
||||
) {
|
||||
std::result::Result::Ok(params) => params,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let selected_client = match state.http_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()),
|
||||
};
|
||||
let response_value =
|
||||
match selected_client.execute_json_rpc_result_raw(method.clone(), params).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let response_json = match serde_json::to_string_pretty(&response_value) {
|
||||
std::result::Result::Ok(text) => text,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let method_class = kb_rpc::HttpClient::classify_method(&method);
|
||||
return std::result::Result::Ok(crate::DemoHttpExecutionPayload {
|
||||
endpoint_name: selected_client.endpoint_name().to_string(),
|
||||
provider: selected_client.provider().to_string(),
|
||||
endpoint_url: selected_client.endpoint_url().to_string(),
|
||||
role,
|
||||
request_kind: kb_rpc::request_kind_from_method(&method),
|
||||
method,
|
||||
method_class: method_class_to_string(method_class).to_string(),
|
||||
response_json,
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn build_http_role_options(
|
||||
snapshots: std::vec::Vec<kb_rpc::HttpPoolClientSnapshot>,
|
||||
) -> std::vec::Vec<crate::DemoHttpRoleOption> {
|
||||
let mut roles = std::collections::BTreeMap::<
|
||||
std::string::String,
|
||||
std::collections::BTreeSet<std::string::String>,
|
||||
>::new();
|
||||
for snapshot in snapshots {
|
||||
for role in snapshot.roles {
|
||||
if !role.enabled {
|
||||
continue;
|
||||
}
|
||||
let role_entry = roles.entry(role.role).or_default();
|
||||
for request_kind in role.request_kinds {
|
||||
role_entry.insert(request_kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut options = std::vec::Vec::new();
|
||||
for (role, request_kinds) in roles {
|
||||
options.push(crate::DemoHttpRoleOption {
|
||||
role,
|
||||
request_kinds: request_kinds.into_iter().collect(),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
pub(crate) fn build_http_method_options() -> std::vec::Vec<crate::DemoHttpMethodOption> {
|
||||
let methods = [
|
||||
("getAccountInfo", "Compte Solana", true, true),
|
||||
("getBalance", "Balance SOL", true, true),
|
||||
("getBlock", "Bloc par slot", true, true),
|
||||
("getBlockCommitment", "Commitment d’un bloc", true, false),
|
||||
("getBlockHeight", "Hauteur de bloc", false, true),
|
||||
("getBlockProduction", "Production de blocs", false, true),
|
||||
("getBlocks", "Liste de blocs", true, true),
|
||||
("getBlocksWithLimit", "Liste de blocs avec limite", true, true),
|
||||
("getClusterNodes", "Nœuds du cluster", false, false),
|
||||
("getEpochInfo", "Époque courante", false, true),
|
||||
("getEpochSchedule", "Planning des époques", false, false),
|
||||
("getFeeForMessage", "Frais pour message", true, true),
|
||||
("getFirstAvailableBlock", "Premier bloc disponible", false, false),
|
||||
("getGenesisHash", "Genesis hash", false, false),
|
||||
("getHealth", "Santé du nœud", false, false),
|
||||
("getHighestSnapshotSlot", "Dernier snapshot", false, false),
|
||||
("getIdentity", "Identité du nœud", false, false),
|
||||
("getInflationGovernor", "Gouverneur inflation", false, true),
|
||||
("getInflationRate", "Taux d’inflation", false, false),
|
||||
("getInflationReward", "Récompense inflation", true, true),
|
||||
("getLargestAccounts", "Plus gros comptes", false, true),
|
||||
("getLatestBlockhash", "Dernier blockhash", false, true),
|
||||
("getLeaderSchedule", "Planning leaders", false, true),
|
||||
("getMaxRetransmitSlot", "Slot retransmis maximum", false, false),
|
||||
("getMaxShredInsertSlot", "Shred insert slot maximum", false, false),
|
||||
("getMinimumBalanceForRentExemption", "Rent exemption", true, true),
|
||||
("getMultipleAccounts", "Comptes multiples", true, true),
|
||||
("getProgramAccounts", "Comptes de programme", true, true),
|
||||
("getRecentPerformanceSamples", "Samples performance", false, true),
|
||||
("getRecentPrioritizationFees", "Frais de priorité récents", false, true),
|
||||
("getSignaturesForAddress", "Signatures par adresse", true, true),
|
||||
("getSignatureStatuses", "Statuts signatures", true, true),
|
||||
("getSlot", "Slot courant", false, true),
|
||||
("getSlotLeader", "Leader du slot", false, true),
|
||||
("getSlotLeaders", "Leaders de slots", true, true),
|
||||
("getStakeActivation", "Activation stake", true, true),
|
||||
("getStakeMinimumDelegation", "Minimum delegation", false, true),
|
||||
("getSupply", "Supply SOL", false, true),
|
||||
("getTokenAccountBalance", "Balance token account", true, true),
|
||||
("getTokenAccountsByDelegate", "Token accounts par delegate", true, true),
|
||||
("getTokenAccountsByOwner", "Token accounts par owner", true, true),
|
||||
("getTokenLargestAccounts", "Plus gros comptes token", true, true),
|
||||
("getTokenSupply", "Supply token", true, true),
|
||||
("getTransaction", "Transaction", true, true),
|
||||
("getTransactionCount", "Nombre de transactions", false, true),
|
||||
("getVersion", "Version du nœud", false, false),
|
||||
("getVoteAccounts", "Vote accounts", false, true),
|
||||
("isBlockhashValid", "Validité blockhash", true, true),
|
||||
("requestAirdrop", "Airdrop devnet", true, true),
|
||||
("sendTransaction", "Envoi de transaction", true, true),
|
||||
("simulateTransaction", "Simulation transaction", true, true),
|
||||
];
|
||||
let mut options = std::vec::Vec::new();
|
||||
for (method, label, requires_first_arg, supports_config_json) in methods {
|
||||
options.push(crate::DemoHttpMethodOption {
|
||||
method: method.to_string(),
|
||||
request_kind: kb_rpc::request_kind_from_method(method),
|
||||
label: label.to_string(),
|
||||
requires_first_arg,
|
||||
supports_config_json,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
fn parse_optional_json(
|
||||
config_json: std::option::Option<std::string::String>,
|
||||
) -> std::result::Result<std::option::Option<serde_json::Value>, std::string::String> {
|
||||
let config_text = match config_json {
|
||||
std::option::Option::Some(value) => value.trim().to_string(),
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
if config_text.is_empty() {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
}
|
||||
let parse_result = serde_json::from_str::<serde_json::Value>(&config_text);
|
||||
return match parse_result {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(format!("invalid configJson: {error}"))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_optional_params_json(
|
||||
params_json: std::option::Option<std::string::String>,
|
||||
) -> std::result::Result<std::option::Option<std::vec::Vec<serde_json::Value>>, std::string::String>
|
||||
{
|
||||
let params_text = match params_json {
|
||||
std::option::Option::Some(value) => value.trim().to_string(),
|
||||
std::option::Option::None => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
if params_text.is_empty() {
|
||||
return std::result::Result::Ok(std::option::Option::None);
|
||||
}
|
||||
let parse_result = serde_json::from_str::<serde_json::Value>(¶ms_text);
|
||||
let value = match parse_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!("invalid paramsJson: {error}"));
|
||||
},
|
||||
};
|
||||
let array = match value.as_array() {
|
||||
std::option::Option::Some(array) => array.clone(),
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err("paramsJson must be a JSON array".to_string());
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(std::option::Option::Some(array));
|
||||
}
|
||||
|
||||
fn build_demo_http_params(
|
||||
method: &str,
|
||||
first_arg: std::option::Option<&str>,
|
||||
config_json: std::option::Option<serde_json::Value>,
|
||||
params_json: std::option::Option<std::vec::Vec<serde_json::Value>>,
|
||||
) -> std::result::Result<std::vec::Vec<serde_json::Value>, std::string::String> {
|
||||
if let std::option::Option::Some(params) = params_json {
|
||||
return std::result::Result::Ok(params);
|
||||
}
|
||||
let needs_first_arg = method == "getBalance"
|
||||
|| method == "getAccountInfo"
|
||||
|| method == "getBlock"
|
||||
|| method == "getBlockCommitment"
|
||||
|| method == "getBlocks"
|
||||
|| method == "getBlocksWithLimit"
|
||||
|| method == "getFeeForMessage"
|
||||
|| method == "getInflationReward"
|
||||
|| method == "getMinimumBalanceForRentExemption"
|
||||
|| method == "getMultipleAccounts"
|
||||
|| method == "getProgramAccounts"
|
||||
|| method == "getSignaturesForAddress"
|
||||
|| method == "getSignatureStatuses"
|
||||
|| method == "getSlotLeaders"
|
||||
|| method == "getStakeActivation"
|
||||
|| method == "getTokenAccountBalance"
|
||||
|| method == "getTokenAccountsByDelegate"
|
||||
|| method == "getTokenAccountsByOwner"
|
||||
|| method == "getTokenLargestAccounts"
|
||||
|| method == "getTokenSupply"
|
||||
|| method == "getTransaction"
|
||||
|| method == "isBlockhashValid"
|
||||
|| method == "requestAirdrop"
|
||||
|| method == "sendTransaction"
|
||||
|| method == "simulateTransaction";
|
||||
if needs_first_arg {
|
||||
let first_arg_value = match first_arg {
|
||||
std::option::Option::Some(value) => value.trim(),
|
||||
std::option::Option::None => "",
|
||||
};
|
||||
if first_arg_value.is_empty() {
|
||||
return std::result::Result::Err(format!("method '{method}' requires firstArg"));
|
||||
}
|
||||
let mut params = std::vec::Vec::new();
|
||||
params.push(serde_json::Value::String(first_arg_value.to_string()));
|
||||
if let std::option::Option::Some(config_value) = config_json {
|
||||
params.push(config_value);
|
||||
}
|
||||
return std::result::Result::Ok(params);
|
||||
}
|
||||
let mut params = std::vec::Vec::new();
|
||||
if let std::option::Option::Some(config_value) = config_json {
|
||||
params.push(config_value);
|
||||
}
|
||||
return std::result::Result::Ok(params);
|
||||
}
|
||||
|
||||
fn method_class_to_string(method_class: kb_rpc::HttpMethodClass) -> &'static str {
|
||||
return match method_class {
|
||||
kb_rpc::HttpMethodClass::GeneralRpc => "GeneralRpc",
|
||||
kb_rpc::HttpMethodClass::SendTransaction => "SendTransaction",
|
||||
kb_rpc::HttpMethodClass::HeavyRead => "HeavyRead",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// file: kb_app_demo/src/demo_spl_ata.rs
|
||||
// version: 3
|
||||
|
||||
//! Thin Tauri adapter for ATA execution, derivation and lifecycle journal reads.
|
||||
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// Request for one representative ATA creation on Devnet.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_ata/DemoExecutionSplAtaRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSplAtaRequest {
|
||||
/// Selected Devnet profile.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Wallet owner used by canonical derivation.
|
||||
pub(crate) wallet_owner: std::string::String,
|
||||
/// Mint account.
|
||||
pub(crate) mint: std::string::String,
|
||||
/// `classic` or `token_2022`.
|
||||
pub(crate) token_program: std::string::String,
|
||||
/// `create` or `create_idempotent`.
|
||||
pub(crate) mode: std::string::String,
|
||||
/// Whether to sign and submit after simulation.
|
||||
pub(crate) submit: bool,
|
||||
/// Explicit operator confirmation.
|
||||
pub(crate) operator_confirmed: bool,
|
||||
/// Whether post-execution outputs should be force-replayed.
|
||||
pub(crate) force_post_validation_replay: bool,
|
||||
}
|
||||
|
||||
/// Request used to derive the representative wallet ATA before execution.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_ata/DemoSplAtaDerivationRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoSplAtaDerivationRequest {
|
||||
/// Selected Devnet profile.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Mint account.
|
||||
pub(crate) mint: std::string::String,
|
||||
/// `classic` or `token_2022`.
|
||||
pub(crate) token_program: std::string::String,
|
||||
}
|
||||
|
||||
/// Readonly payer, wallet, Token Program and derived ATA values.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_ata/DemoSplAtaDerivationPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoSplAtaDerivationPayload {
|
||||
/// Profile wallet used as payer.
|
||||
pub(crate) payer: std::string::String,
|
||||
/// Wallet owner selected by the representative panel.
|
||||
pub(crate) wallet_owner: std::string::String,
|
||||
/// Exact Token Program ID.
|
||||
pub(crate) token_program_id: std::string::String,
|
||||
/// Canonically derived ATA.
|
||||
pub(crate) associated_token_account: std::string::String,
|
||||
}
|
||||
|
||||
/// UI-safe ATA execution result.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_ata/DemoExecutionSplAtaSummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSplAtaSummaryPayload {
|
||||
/// Stable operation code.
|
||||
pub(crate) operation: std::string::String,
|
||||
/// Profile name.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Profile wallet public key.
|
||||
pub(crate) wallet_public_key: std::string::String,
|
||||
/// Target Token Program ID.
|
||||
pub(crate) token_program_id: std::string::String,
|
||||
/// Derived ATA from the exact plan.
|
||||
pub(crate) associated_token_account: std::string::String,
|
||||
/// Wallet balance as lossless JSON text.
|
||||
pub(crate) balance_lamports: std::string::String,
|
||||
/// Fee estimate as lossless JSON text.
|
||||
pub(crate) fee_lamports: std::option::Option<std::string::String>,
|
||||
/// Aggregate stateful readiness.
|
||||
pub(crate) readiness_status: std::string::String,
|
||||
/// Exact simulation outcome.
|
||||
pub(crate) simulation_success: bool,
|
||||
/// Runtime simulation error.
|
||||
pub(crate) simulation_error: std::option::Option<std::string::String>,
|
||||
/// Submitted signature.
|
||||
pub(crate) transaction_signature: std::option::Option<std::string::String>,
|
||||
/// Terminal confirmation status.
|
||||
pub(crate) confirmation_status: std::option::Option<std::string::String>,
|
||||
/// Number of ATA-owned materialized facts.
|
||||
pub(crate) materialization_count: u32,
|
||||
/// Whether the second replay created no output.
|
||||
pub(crate) idempotence_validated: bool,
|
||||
/// Exact plan JSON.
|
||||
pub(crate) plan_json: std::string::String,
|
||||
/// Stateful readiness JSON.
|
||||
pub(crate) readiness_json: std::string::String,
|
||||
/// Simulation JSON.
|
||||
pub(crate) simulation_json: std::string::String,
|
||||
/// Confirmation and replay diagnostics JSON.
|
||||
pub(crate) diagnostics_json: std::string::String,
|
||||
}
|
||||
|
||||
/// Bounded ATA lifecycle journal request.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_ata/DemoSplAtaJournalRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoSplAtaJournalRequest {
|
||||
/// Selected Devnet profile.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Optional partial signature.
|
||||
pub(crate) signature_contains: std::option::Option<std::string::String>,
|
||||
/// Optional exact mint.
|
||||
pub(crate) mint: std::option::Option<std::string::String>,
|
||||
/// Optional exact ATA.
|
||||
pub(crate) associated_token_account: std::option::Option<std::string::String>,
|
||||
/// Optional exact materialized operation.
|
||||
pub(crate) operation: std::option::Option<std::string::String>,
|
||||
/// Maximum returned rows.
|
||||
pub(crate) limit: u32,
|
||||
}
|
||||
|
||||
/// One UI-safe ATA lifecycle journal row.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_ata/DemoSplAtaJournalRow.ts"
|
||||
)]
|
||||
pub(crate) struct DemoSplAtaJournalRow {
|
||||
/// Transaction signature.
|
||||
pub(crate) signature: std::string::String,
|
||||
/// Slot as lossless JSON text.
|
||||
pub(crate) slot: std::string::String,
|
||||
/// Materialized family.
|
||||
pub(crate) family: std::string::String,
|
||||
/// Lifecycle operation.
|
||||
pub(crate) operation: std::option::Option<std::string::String>,
|
||||
/// Exact mint when present.
|
||||
pub(crate) mint: std::option::Option<std::string::String>,
|
||||
/// Exact ATA when present.
|
||||
pub(crate) associated_token_account: std::option::Option<std::string::String>,
|
||||
/// Full bounded payload JSON.
|
||||
pub(crate) payload_json: std::string::String,
|
||||
}
|
||||
|
||||
pub(crate) async fn load_profile_wallet(
|
||||
profile: &kb_config::ProfileConfig,
|
||||
) -> std::result::Result<kb_wallet::TemporaryWallet, std::string::String> {
|
||||
let configured = std::path::PathBuf::from(profile.wallet.wallet_dir.as_str());
|
||||
let directory = if configured.is_absolute() {
|
||||
configured
|
||||
} else {
|
||||
crate::workspace_root_dir().join(configured)
|
||||
};
|
||||
let store = match kb_wallet::TemporaryWalletStore::new(directory) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let alias = match kb_wallet::WalletAlias::parse(profile.wallet.temporary_wallet_alias.clone()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
return match store.load_or_create(alias).await {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn parse_token_program(
|
||||
value: &str,
|
||||
) -> std::result::Result<
|
||||
kb_executor_spl_associated_token_account::SplAssociatedTokenProgram,
|
||||
std::string::String,
|
||||
> {
|
||||
return match value.trim() {
|
||||
"classic" => std::result::Result::Ok(
|
||||
kb_executor_spl_associated_token_account::SplAssociatedTokenProgram::Classic,
|
||||
),
|
||||
"token_2022" => std::result::Result::Ok(
|
||||
kb_executor_spl_associated_token_account::SplAssociatedTokenProgram::Token2022,
|
||||
),
|
||||
_ => std::result::Result::Err("Token Program must be classic or token_2022".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn derive_ata(
|
||||
wallet: &str,
|
||||
mint: &str,
|
||||
token_program: &str,
|
||||
) -> std::result::Result<std::string::String, std::string::String> {
|
||||
let wallet: solana_pubkey::Pubkey = match wallet.parse() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!("invalid wallet: {error}"));
|
||||
},
|
||||
};
|
||||
let mint: solana_pubkey::Pubkey = match mint.parse() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!("invalid mint: {error}"));
|
||||
},
|
||||
};
|
||||
let token_program: solana_pubkey::Pubkey = match token_program.parse() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!("invalid Token Program: {error}"));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(
|
||||
spl_associated_token_account_interface::address::get_associated_token_address_with_program_id(
|
||||
&wallet,
|
||||
&mint,
|
||||
&token_program,
|
||||
)
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn demo_spl_ata_summary_payload(
|
||||
summary: kb_pipeline::DevnetSplAssociatedTokenAccountExecutionSummary,
|
||||
) -> crate::DemoExecutionSplAtaSummaryPayload {
|
||||
let (associated_token_account, token_program_id) = match summary.plan.instructions.first() {
|
||||
std::option::Option::Some(instruction) => {
|
||||
let ata = match instruction.accounts.get(1) {
|
||||
std::option::Option::Some(account) => account.pubkey.0.clone(),
|
||||
std::option::Option::None => std::string::String::new(),
|
||||
};
|
||||
let token_program = match instruction.accounts.last() {
|
||||
std::option::Option::Some(account) => account.pubkey.0.clone(),
|
||||
std::option::Option::None => std::string::String::new(),
|
||||
};
|
||||
(ata, token_program)
|
||||
},
|
||||
std::option::Option::None => (std::string::String::new(), std::string::String::new()),
|
||||
};
|
||||
let transaction_signature =
|
||||
summary.send_result.as_ref().map(|value| return value.signature.0.clone());
|
||||
let confirmation_status = summary.confirmation.as_ref().map(|value| {
|
||||
return crate::confirmation_status_code(value.status);
|
||||
});
|
||||
let idempotence_validated = summary.idempotence_replay.as_ref().is_some_and(|replay| {
|
||||
return replay.failed_inputs == 0
|
||||
&& replay.processors.iter().all(|processor| {
|
||||
return processor.failed == 0
|
||||
&& processor.materialized_outputs == 0
|
||||
&& processor.materialization_refused == 0;
|
||||
});
|
||||
});
|
||||
let materialization_count = match u32::try_from(summary.materializations.len()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => u32::MAX,
|
||||
};
|
||||
let diagnostics = serde_json::json!({
|
||||
"confirmation": summary.confirmation.as_ref(),
|
||||
"postStateValidation": summary.post_state_validation.as_ref(),
|
||||
"postExecution": summary.post_execution.as_ref(),
|
||||
"backfill": summary.backfill.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"candidatesCompleted": value.candidates_completed,
|
||||
"candidatesCancelled": value.candidates_cancelled,
|
||||
"candidatesNotStarted": value.candidates_not_started,
|
||||
"canonicalInserted": value.canonical_inserted,
|
||||
"canonicalSkipped": value.canonical_skipped,
|
||||
"existingSkipped": value.existing_skipped,
|
||||
"missing": value.missing,
|
||||
"failed": value.failed
|
||||
});
|
||||
}),
|
||||
"coreExtraction": summary.core_extraction.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"selected": value.selected,
|
||||
"extracted": value.extracted,
|
||||
"skipped": value.skipped,
|
||||
"failed": value.failed,
|
||||
"cancelled": value.cancelled
|
||||
});
|
||||
}),
|
||||
"decodeReplay": summary.decode_replay.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"campaignId": value.campaign_id,
|
||||
"selected": value.selected,
|
||||
"completed": value.completed,
|
||||
"unmatched": value.unmatched,
|
||||
"failedInputs": value.failed_inputs,
|
||||
"cancelled": value.cancelled
|
||||
});
|
||||
}),
|
||||
"idempotenceReplay": summary.idempotence_replay.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"campaignId": value.campaign_id,
|
||||
"selected": value.selected,
|
||||
"completed": value.completed,
|
||||
"unmatched": value.unmatched,
|
||||
"failedInputs": value.failed_inputs,
|
||||
"cancelled": value.cancelled,
|
||||
"materializedOutputs": value.processors.iter().map(|processor| {
|
||||
return processor.materialized_outputs;
|
||||
}).sum::<u64>(),
|
||||
"materializationRefused": value.processors.iter().map(|processor| {
|
||||
return processor.materialization_refused;
|
||||
}).sum::<u64>()
|
||||
});
|
||||
}),
|
||||
"materializations": summary.materializations.iter().map(|value| {
|
||||
return serde_json::json!({
|
||||
"processorName": value.processor_name,
|
||||
"outputKey": value.output_key,
|
||||
"family": value.materialized_family,
|
||||
"signature": value.signature,
|
||||
"slot": value.slot.to_string(),
|
||||
"payload": value.payload_json
|
||||
});
|
||||
}).collect::<std::vec::Vec<_>>()
|
||||
});
|
||||
let plan_json = crate::pretty_json(&summary.plan);
|
||||
let readiness_json = crate::pretty_json(&summary.stateful_readiness);
|
||||
let simulation_json = crate::pretty_json(&summary.simulation);
|
||||
let diagnostics_json = crate::pretty_json(&diagnostics);
|
||||
return crate::DemoExecutionSplAtaSummaryPayload {
|
||||
operation: summary.stateful_readiness.operation_code,
|
||||
profile_name: summary.profile_name,
|
||||
wallet_public_key: summary.wallet.public_key,
|
||||
token_program_id,
|
||||
associated_token_account,
|
||||
balance_lamports: summary.balance_lamports.to_string(),
|
||||
fee_lamports: summary.fee.fee_lamports.map(|value| return value.to_string()),
|
||||
readiness_status: match summary.stateful_readiness.status {
|
||||
kb_pipeline::SplAssociatedTokenAccountStatefulReadinessStatus::Ready => {
|
||||
"ready".to_string()
|
||||
},
|
||||
kb_pipeline::SplAssociatedTokenAccountStatefulReadinessStatus::Blocked => {
|
||||
"blocked".to_string()
|
||||
},
|
||||
},
|
||||
simulation_success: summary.simulation.success,
|
||||
simulation_error: summary.simulation.error,
|
||||
transaction_signature,
|
||||
confirmation_status,
|
||||
materialization_count,
|
||||
idempotence_validated,
|
||||
plan_json,
|
||||
readiness_json,
|
||||
simulation_json,
|
||||
diagnostics_json,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn journal_matches(
|
||||
payload: &serde_json::Value,
|
||||
request: &crate::DemoSplAtaJournalRequest,
|
||||
) -> bool {
|
||||
for (expected, key) in [
|
||||
(&request.operation, "operation"),
|
||||
(&request.mint, "mint"),
|
||||
(&request.associated_token_account, "associatedTokenAccount"),
|
||||
] {
|
||||
if expected.as_ref().is_some_and(|value| {
|
||||
return payload.get(key).and_then(serde_json::Value::as_str)
|
||||
!= std::option::Option::Some(value.as_str());
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
pub(crate) fn journal_row(
|
||||
row: kb_store_core::MaterializedEventQueryRow,
|
||||
) -> crate::DemoSplAtaJournalRow {
|
||||
let text = |key: &str| {
|
||||
return row
|
||||
.payload_json
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(std::string::ToString::to_string);
|
||||
};
|
||||
let operation = text("operation");
|
||||
let mint = text("mint");
|
||||
let associated_token_account = text("associatedTokenAccount");
|
||||
let payload_json = crate::pretty_json(&row.payload_json);
|
||||
return crate::DemoSplAtaJournalRow {
|
||||
signature: row.signature,
|
||||
slot: row.slot.to_string(),
|
||||
family: row.materialized_family,
|
||||
operation,
|
||||
mint,
|
||||
associated_token_account,
|
||||
payload_json,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn classic_and_token_2022_derivations_are_distinct_and_canonical() {
|
||||
let wallet = kb_program_ids::SYSTEM_PROGRAM_ID;
|
||||
let mint = "So11111111111111111111111111111111111111112";
|
||||
let classic = crate::derive_ata(wallet, mint, kb_program_ids::SPL_TOKEN_PROGRAM_ID)
|
||||
.unwrap_or_else(|error| panic!("classic derivation failed: {error}"));
|
||||
let token_2022 = crate::derive_ata(wallet, mint, kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID)
|
||||
.unwrap_or_else(|error| panic!("Token-2022 derivation failed: {error}"));
|
||||
assert_eq!(classic, "aqxoAhCwpy3oB1BpNw9hL1HdLYLgPpbPjzxDrrQj3Fs");
|
||||
assert_eq!(token_2022, "2sZUUBGq1i6aE47ZoxCaCW89jmYm2EXLPPmNMgMDXHMS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_journal_filters_are_exact() {
|
||||
let request = crate::DemoSplAtaJournalRequest {
|
||||
profile_name: "local_devnet".to_string(),
|
||||
signature_contains: std::option::Option::None,
|
||||
mint: std::option::Option::Some("mint111".to_string()),
|
||||
associated_token_account: std::option::Option::Some("ata111".to_string()),
|
||||
operation: std::option::Option::Some("create_idempotent".to_string()),
|
||||
limit: 100,
|
||||
};
|
||||
assert!(crate::journal_matches(
|
||||
&serde_json::json!({
|
||||
"operation": "create_idempotent",
|
||||
"mint": "mint111",
|
||||
"associatedTokenAccount": "ata111"
|
||||
}),
|
||||
&request,
|
||||
));
|
||||
assert!(!crate::journal_matches(
|
||||
&serde_json::json!({
|
||||
"operation": "create",
|
||||
"mint": "mint111",
|
||||
"associatedTokenAccount": "ata111"
|
||||
}),
|
||||
&request,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
// file: kb_app_demo/src/demo_spl_token.rs
|
||||
// version: 2
|
||||
|
||||
//! Thin Tauri adapter for classic SPL Token execution and materialized journals.
|
||||
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// Request for one checked classic SPL Token transfer on Devnet.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_token/DemoExecutionSplTokenRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSplTokenRequest {
|
||||
/// Selected Devnet profile.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Source token account.
|
||||
pub(crate) source: std::string::String,
|
||||
/// Exact mint account carried by `TransferChecked`.
|
||||
pub(crate) mint: std::string::String,
|
||||
/// Destination token account.
|
||||
pub(crate) destination: std::string::String,
|
||||
/// Simple authority resolved by the selected profile wallet.
|
||||
pub(crate) authority: std::string::String,
|
||||
/// Exact raw amount represented as a decimal string.
|
||||
pub(crate) amount_raw: std::string::String,
|
||||
/// Expected mint decimals carried by the wire.
|
||||
pub(crate) decimals: u8,
|
||||
/// Whether the transaction should be signed and submitted after simulation.
|
||||
pub(crate) submit: bool,
|
||||
/// Explicit operator confirmation for signed submission.
|
||||
pub(crate) operator_confirmed: bool,
|
||||
/// Whether post-execution core and decode outputs should be force-replayed.
|
||||
pub(crate) force_post_validation_replay: bool,
|
||||
}
|
||||
|
||||
/// UI-safe result of one checked SPL Token transfer orchestration.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_token/DemoExecutionSplTokenSummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSplTokenSummaryPayload {
|
||||
/// Stable operation code.
|
||||
pub(crate) operation: std::string::String,
|
||||
/// Profile used by the orchestration.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Classified cluster code.
|
||||
pub(crate) cluster: std::string::String,
|
||||
/// Genesis hash returned by the endpoint.
|
||||
pub(crate) genesis_hash: std::string::String,
|
||||
/// Source wallet public key.
|
||||
pub(crate) wallet_public_key: std::string::String,
|
||||
/// Exact wallet balance rendered without JSON integer precision loss.
|
||||
pub(crate) balance_lamports: std::string::String,
|
||||
/// Estimated fee rendered without JSON integer precision loss.
|
||||
pub(crate) fee_lamports: std::option::Option<std::string::String>,
|
||||
/// Exact raw token amount.
|
||||
pub(crate) amount_raw: std::string::String,
|
||||
/// Expected mint decimals.
|
||||
pub(crate) decimals: u8,
|
||||
/// Aggregate stateful preflight status.
|
||||
pub(crate) readiness_status: std::string::String,
|
||||
/// Whether exact simulation succeeded.
|
||||
pub(crate) simulation_success: bool,
|
||||
/// Runtime simulation error when present.
|
||||
pub(crate) simulation_error: std::option::Option<std::string::String>,
|
||||
/// Submitted transaction signature.
|
||||
pub(crate) transaction_signature: std::option::Option<std::string::String>,
|
||||
/// Terminal confirmation status.
|
||||
pub(crate) confirmation_status: std::option::Option<std::string::String>,
|
||||
/// Whether canonical hydration was validated.
|
||||
pub(crate) canonical_inserted: bool,
|
||||
/// Whether core extraction was validated.
|
||||
pub(crate) core_extracted: bool,
|
||||
/// Whether SPL Token decode replay was validated.
|
||||
pub(crate) decode_replayed: bool,
|
||||
/// Number of materialized rows for the submitted instruction.
|
||||
pub(crate) materialization_count: u32,
|
||||
/// Whether the second replay produced no failure or new output.
|
||||
pub(crate) idempotence_validated: bool,
|
||||
/// Pretty JSON representation of the exact execution plan.
|
||||
pub(crate) plan_json: std::string::String,
|
||||
/// Pretty JSON representation of stateful readiness.
|
||||
pub(crate) readiness_json: std::string::String,
|
||||
/// Pretty JSON representation of the exact simulation result.
|
||||
pub(crate) simulation_json: std::string::String,
|
||||
/// Pretty JSON representation of confirmation and replay diagnostics.
|
||||
pub(crate) diagnostics_json: std::string::String,
|
||||
}
|
||||
|
||||
/// Bounded exact filters for the classic SPL Token materialized journal.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_token/DemoSplTokenJournalRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoSplTokenJournalRequest {
|
||||
/// Devnet profile whose PostgreSQL store is queried.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Optional partial signature handled by the bounded store query.
|
||||
pub(crate) signature_contains: std::option::Option<std::string::String>,
|
||||
/// Optional exact mint account.
|
||||
pub(crate) mint: std::option::Option<std::string::String>,
|
||||
/// Optional exact account occurring in the ordered account list.
|
||||
pub(crate) account: std::option::Option<std::string::String>,
|
||||
/// Optional exact materialized family.
|
||||
pub(crate) family: std::option::Option<std::string::String>,
|
||||
/// Optional exact operation code without the `spl_token.` prefix.
|
||||
pub(crate) operation: std::option::Option<std::string::String>,
|
||||
/// Maximum number of rows returned after typed filtering.
|
||||
pub(crate) limit: u32,
|
||||
}
|
||||
|
||||
/// UI-safe materialized classic SPL Token journal row.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_token/DemoSplTokenJournalRow.ts"
|
||||
)]
|
||||
pub(crate) struct DemoSplTokenJournalRow {
|
||||
/// Materializer processor name.
|
||||
pub(crate) processor_name: std::string::String,
|
||||
/// Materializer processor version.
|
||||
pub(crate) processor_version: std::string::String,
|
||||
/// Stable processor-owned output key.
|
||||
pub(crate) output_key: std::string::String,
|
||||
/// Transaction signature.
|
||||
pub(crate) signature: std::string::String,
|
||||
/// Decimal slot rendered as text to preserve JSON precision.
|
||||
pub(crate) slot: std::string::String,
|
||||
/// Materialized family code.
|
||||
pub(crate) family: std::string::String,
|
||||
/// Exact operation when present.
|
||||
pub(crate) operation: std::option::Option<std::string::String>,
|
||||
/// Exact mint account when explicitly available.
|
||||
pub(crate) mint: std::option::Option<std::string::String>,
|
||||
/// Exact raw amount when carried by the materialized fact.
|
||||
pub(crate) amount_raw: std::option::Option<std::string::String>,
|
||||
/// Stable outer or inner instruction path when present.
|
||||
pub(crate) instruction_path: std::option::Option<std::string::String>,
|
||||
/// Ordered account keys preserved by the decoder.
|
||||
pub(crate) account_keys: std::vec::Vec<std::string::String>,
|
||||
/// Complete bounded typed payload rendered as JSON text.
|
||||
pub(crate) payload_json: std::string::String,
|
||||
/// Database creation timestamp.
|
||||
pub(crate) created_at: std::string::String,
|
||||
/// Database replacement timestamp.
|
||||
pub(crate) updated_at: std::string::String,
|
||||
}
|
||||
|
||||
pub(crate) fn demo_spl_token_summary_payload(
|
||||
summary: kb_pipeline::DevnetSplTokenExecutionSummary,
|
||||
amount_raw: std::string::String,
|
||||
decimals: u8,
|
||||
) -> crate::DemoExecutionSplTokenSummaryPayload {
|
||||
let plan_json = crate::pretty_json(&summary.plan);
|
||||
let readiness_json = crate::pretty_json(&summary.stateful_readiness);
|
||||
let simulation_json = crate::pretty_json(&summary.simulation);
|
||||
let transaction_signature =
|
||||
summary.send_result.as_ref().map(|value| return value.signature.0.clone());
|
||||
let confirmation_status = summary.confirmation.as_ref().map(|value| {
|
||||
return crate::confirmation_status_code(value.status);
|
||||
});
|
||||
let post_execution = summary.post_execution.as_ref();
|
||||
let canonical_inserted = post_execution.is_some_and(|value| return value.canonical_inserted);
|
||||
let core_extracted = post_execution.is_some_and(|value| return value.core_extracted);
|
||||
let decode_replayed = post_execution.is_some_and(|value| return value.decode_replayed);
|
||||
let idempotence_validated = summary.idempotence_replay.as_ref().is_some_and(|replay| {
|
||||
return replay.failed_inputs == 0
|
||||
&& !replay.cancelled
|
||||
&& replay.processors.iter().all(|processor| {
|
||||
return processor.failed == 0
|
||||
&& processor.materialized_outputs == 0
|
||||
&& processor.materialization_refused == 0;
|
||||
});
|
||||
});
|
||||
let materialization_count = match u32::try_from(summary.materializations.len()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => u32::MAX,
|
||||
};
|
||||
let diagnostics = serde_json::json!({
|
||||
"confirmation": summary.confirmation,
|
||||
"postExecution": summary.post_execution,
|
||||
"backfill": summary.backfill.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"canonicalInserted": value.canonical_inserted,
|
||||
"canonicalSkipped": value.canonical_skipped,
|
||||
"existingSkipped": value.existing_skipped,
|
||||
"missing": value.missing,
|
||||
"failed": value.failed
|
||||
});
|
||||
}),
|
||||
"coreExtraction": summary.core_extraction.as_ref().map(|value| {
|
||||
return serde_json::json!({
|
||||
"selected": value.selected,
|
||||
"extracted": value.extracted,
|
||||
"skipped": value.skipped,
|
||||
"failed": value.failed,
|
||||
"cancelled": value.cancelled
|
||||
});
|
||||
}),
|
||||
"decodeReplay": summary.decode_replay.as_ref().map(|value| {
|
||||
return replay_diagnostics(value);
|
||||
}),
|
||||
"idempotenceReplay": summary.idempotence_replay.as_ref().map(|value| {
|
||||
return replay_diagnostics(value);
|
||||
}),
|
||||
"materializations": summary.materializations
|
||||
});
|
||||
return crate::DemoExecutionSplTokenSummaryPayload {
|
||||
operation: summary.stateful_readiness.operation_code,
|
||||
profile_name: summary.profile_name,
|
||||
cluster: crate::execution_cluster_code(summary.cluster),
|
||||
genesis_hash: summary.genesis_hash,
|
||||
wallet_public_key: summary.wallet.public_key,
|
||||
balance_lamports: summary.balance_lamports.to_string(),
|
||||
fee_lamports: summary.fee.fee_lamports.map(|value| return value.to_string()),
|
||||
amount_raw,
|
||||
decimals,
|
||||
readiness_status: match summary.stateful_readiness.status {
|
||||
kb_pipeline::SplTokenStatefulReadinessStatus::Ready => "ready".to_string(),
|
||||
kb_pipeline::SplTokenStatefulReadinessStatus::Blocked => "blocked".to_string(),
|
||||
},
|
||||
simulation_success: summary.simulation.success,
|
||||
simulation_error: summary.simulation.error,
|
||||
transaction_signature,
|
||||
confirmation_status,
|
||||
canonical_inserted,
|
||||
core_extracted,
|
||||
decode_replayed,
|
||||
materialization_count,
|
||||
idempotence_validated,
|
||||
plan_json,
|
||||
readiness_json,
|
||||
simulation_json,
|
||||
diagnostics_json: crate::pretty_json(&diagnostics),
|
||||
};
|
||||
}
|
||||
|
||||
fn replay_diagnostics(summary: &kb_pipeline::DecodeReplaySummary) -> serde_json::Value {
|
||||
return serde_json::json!({
|
||||
"campaignId": summary.campaign_id,
|
||||
"selected": summary.selected,
|
||||
"completed": summary.completed,
|
||||
"failedInputs": summary.failed_inputs,
|
||||
"cancelled": summary.cancelled,
|
||||
"processors": summary.processors.iter().map(|processor| {
|
||||
return serde_json::json!({
|
||||
"name": processor.processor_name,
|
||||
"version": processor.processor_version,
|
||||
"decoded": processor.decoded,
|
||||
"skipped": processor.skipped,
|
||||
"failed": processor.failed,
|
||||
"materializedOutputs": processor.materialized_outputs,
|
||||
"materializationRefused": processor.materialization_refused
|
||||
});
|
||||
}).collect::<std::vec::Vec<_>>()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn validate_journal_request(
|
||||
request: crate::DemoSplTokenJournalRequest,
|
||||
) -> std::result::Result<crate::DemoSplTokenJournalRequest, std::string::String> {
|
||||
if request.limit == 0 || request.limit > kb_store_core::MAX_MATERIALIZED_EVENT_QUERY_ROWS {
|
||||
return std::result::Result::Err(format!(
|
||||
"journal limit must be between 1 and {}",
|
||||
kb_store_core::MAX_MATERIALIZED_EVENT_QUERY_ROWS
|
||||
));
|
||||
}
|
||||
if request.profile_name.trim().is_empty() {
|
||||
return std::result::Result::Err("journal profile name must not be empty".to_string());
|
||||
}
|
||||
let signature_contains =
|
||||
match bounded_optional(request.signature_contains, "signature filter", 128) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mint = match bounded_optional(request.mint, "mint filter", 64) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let account = match bounded_optional(request.account, "account filter", 64) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let operation = match bounded_optional(request.operation, "operation filter", 64) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let family = match bounded_optional(request.family, "family filter", 32) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if family.as_ref().is_some_and(|value| {
|
||||
return !matches!(value.as_str(), "token_account" | "admin" | "risk");
|
||||
}) {
|
||||
return std::result::Result::Err(
|
||||
"journal family must be token_account, admin or risk".to_string(),
|
||||
);
|
||||
}
|
||||
return std::result::Result::Ok(crate::DemoSplTokenJournalRequest {
|
||||
profile_name: request.profile_name.trim().to_string(),
|
||||
signature_contains,
|
||||
mint,
|
||||
account,
|
||||
family,
|
||||
operation,
|
||||
limit: request.limit,
|
||||
});
|
||||
}
|
||||
|
||||
fn bounded_optional(
|
||||
value: std::option::Option<std::string::String>,
|
||||
label: &str,
|
||||
maximum_length: usize,
|
||||
) -> std::result::Result<std::option::Option<std::string::String>, std::string::String> {
|
||||
let trimmed = value.map(|text| return text.trim().to_string());
|
||||
let trimmed = trimmed.filter(|text| return !text.is_empty());
|
||||
if trimmed.as_ref().is_some_and(|text| return text.len() > maximum_length) {
|
||||
return std::result::Result::Err(format!("{label} must not exceed {maximum_length} bytes"));
|
||||
}
|
||||
return std::result::Result::Ok(trimmed);
|
||||
}
|
||||
|
||||
pub(crate) fn payload_matches(
|
||||
payload: &serde_json::Value,
|
||||
request: &crate::DemoSplTokenJournalRequest,
|
||||
) -> bool {
|
||||
if request.operation.as_ref().is_some_and(|expected| {
|
||||
return payload.get("operation").and_then(serde_json::Value::as_str)
|
||||
!= std::option::Option::Some(expected.as_str());
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
if request.mint.as_ref().is_some_and(|expected| {
|
||||
return payload_mint(payload).as_deref() != std::option::Option::Some(expected.as_str());
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
if request.account.as_ref().is_some_and(|expected| {
|
||||
return !payload_account_keys(payload).iter().any(|value| return value == expected);
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
pub(crate) fn demo_spl_token_journal_row(
|
||||
row: kb_store_core::MaterializedEventQueryRow,
|
||||
) -> DemoSplTokenJournalRow {
|
||||
let operation = row
|
||||
.payload_json
|
||||
.get("operation")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(std::string::ToString::to_string);
|
||||
let amount_raw = row
|
||||
.payload_json
|
||||
.get("amountRaw")
|
||||
.or_else(|| {
|
||||
return row
|
||||
.payload_json
|
||||
.get("parameters")
|
||||
.and_then(|value| return value.get("amountRaw"));
|
||||
})
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(std::string::ToString::to_string);
|
||||
let instruction_path = row
|
||||
.payload_json
|
||||
.get("instructionPath")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(std::string::ToString::to_string);
|
||||
let payload_json = crate::pretty_json(&row.payload_json);
|
||||
return DemoSplTokenJournalRow {
|
||||
processor_name: row.processor_name,
|
||||
processor_version: row.processor_version,
|
||||
output_key: row.output_key,
|
||||
signature: row.signature,
|
||||
slot: row.slot.to_string(),
|
||||
family: row.materialized_family,
|
||||
operation,
|
||||
mint: payload_mint(&row.payload_json),
|
||||
amount_raw,
|
||||
instruction_path,
|
||||
account_keys: payload_account_keys(&row.payload_json),
|
||||
payload_json,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
fn payload_mint(payload: &serde_json::Value) -> std::option::Option<std::string::String> {
|
||||
let direct = payload.get("mint").and_then(serde_json::Value::as_str);
|
||||
if let std::option::Option::Some(value) = direct {
|
||||
return std::option::Option::Some(value.to_string());
|
||||
}
|
||||
return payload.get("accounts").and_then(serde_json::Value::as_array).and_then(|rows| {
|
||||
return rows.iter().find_map(|row| {
|
||||
if row.get("role").and_then(serde_json::Value::as_str)
|
||||
!= std::option::Option::Some("mint")
|
||||
{
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return row
|
||||
.get("accountKey")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(std::string::ToString::to_string);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn payload_account_keys(payload: &serde_json::Value) -> std::vec::Vec<std::string::String> {
|
||||
let values = payload.get("accounts").and_then(serde_json::Value::as_array).map(|rows| {
|
||||
return rows
|
||||
.iter()
|
||||
.filter_map(|row| {
|
||||
return row
|
||||
.get("accountKey")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(std::string::ToString::to_string);
|
||||
})
|
||||
.collect();
|
||||
});
|
||||
return match values {
|
||||
std::option::Option::Some(values) => values,
|
||||
std::option::Option::None => std::vec::Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn journal_filters_are_exact_bounded_and_preserve_raw_amounts() {
|
||||
let request = crate::DemoSplTokenJournalRequest {
|
||||
profile_name: "local_devnet".to_string(),
|
||||
signature_contains: std::option::Option::None,
|
||||
mint: std::option::Option::Some("mint111".to_string()),
|
||||
account: std::option::Option::Some("source111".to_string()),
|
||||
family: std::option::Option::Some("token_account".to_string()),
|
||||
operation: std::option::Option::Some("transfer_checked".to_string()),
|
||||
limit: 25,
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"operation": "transfer_checked",
|
||||
"mint": "mint111",
|
||||
"amountRaw": "18446744073709551615",
|
||||
"accounts": [
|
||||
{"role": "source", "accountKey": "source111"},
|
||||
{"role": "mint", "accountKey": "mint111"}
|
||||
]
|
||||
});
|
||||
assert!(crate::payload_matches(&payload, &request));
|
||||
assert_eq!(
|
||||
payload.get("amountRaw").and_then(serde_json::Value::as_str),
|
||||
std::option::Option::Some("18446744073709551615")
|
||||
);
|
||||
let row = crate::demo_spl_token_journal_row(kb_store_core::MaterializedEventQueryRow {
|
||||
processor_name: "spl_token_accounts".to_string(),
|
||||
processor_version: "0.4.4".to_string(),
|
||||
input_key: "input".to_string(),
|
||||
output_key: "output".to_string(),
|
||||
source_event_key: "event".to_string(),
|
||||
source_decoder_name: "spl_token".to_string(),
|
||||
source_decoder_version: "0.4.4".to_string(),
|
||||
signature: "signature".to_string(),
|
||||
slot: u64::MAX,
|
||||
materialized_family: "token_account".to_string(),
|
||||
payload_json: payload,
|
||||
created_at: "created".to_string(),
|
||||
updated_at: "updated".to_string(),
|
||||
});
|
||||
assert_eq!(row.slot, u64::MAX.to_string());
|
||||
assert_eq!(row.amount_raw, std::option::Option::Some(u64::MAX.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_rejects_unbounded_or_unknown_family_requests() {
|
||||
let request = crate::DemoSplTokenJournalRequest {
|
||||
profile_name: "local_devnet".to_string(),
|
||||
signature_contains: std::option::Option::None,
|
||||
mint: std::option::Option::None,
|
||||
account: std::option::Option::None,
|
||||
family: std::option::Option::Some("fee".to_string()),
|
||||
operation: std::option::Option::None,
|
||||
limit: 501,
|
||||
};
|
||||
assert!(crate::validate_journal_request(request).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// file: kb_app_demo/src/demo_spl_token_2022.rs
|
||||
// version: 3
|
||||
|
||||
//! Thin Tauri adapter for independent public Token-2022 Devnet validation scenarios.
|
||||
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// Public values loaded from one persisted Token-2022 validation fixture.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_token_2022/DemoSplToken2022FixturePayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoSplToken2022FixturePayload {
|
||||
/// Fixture file used by the application.
|
||||
pub(crate) fixture_path: std::string::String,
|
||||
/// Token-2022 program ID.
|
||||
pub(crate) program_id: std::string::String,
|
||||
/// Mint account.
|
||||
pub(crate) mint: std::string::String,
|
||||
/// Source token account.
|
||||
pub(crate) source: std::string::String,
|
||||
/// Destination token account.
|
||||
pub(crate) destination: std::string::String,
|
||||
/// Dedicated empty account reserved for CloseAccount validation.
|
||||
pub(crate) close_account: std::string::String,
|
||||
/// Delegate account.
|
||||
pub(crate) delegate: std::string::String,
|
||||
/// Profile-wallet authority.
|
||||
pub(crate) authority: std::string::String,
|
||||
/// Freeze authority when configured on the mint.
|
||||
pub(crate) freeze_authority: std::string::String,
|
||||
/// Mint decimals.
|
||||
pub(crate) decimals: u8,
|
||||
/// Default raw amount for the selected scenario.
|
||||
pub(crate) default_amount_raw: std::string::String,
|
||||
}
|
||||
|
||||
/// Request for one independent public Token-2022 Devnet scenario.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_token_2022/DemoExecutionSplToken2022Request.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSplToken2022Request {
|
||||
/// Selected Devnet profile.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Stable scenario identifier.
|
||||
pub(crate) scenario_id: std::string::String,
|
||||
/// Source or target token account depending on the selected operation.
|
||||
pub(crate) source: std::string::String,
|
||||
/// Exact mint account.
|
||||
pub(crate) mint: std::string::String,
|
||||
/// Destination token account or close-account lamport destination.
|
||||
pub(crate) destination: std::string::String,
|
||||
/// Delegate account used by `ApproveChecked`.
|
||||
pub(crate) delegate: std::string::String,
|
||||
/// Owner or mint authority resolved by the selected profile wallet.
|
||||
pub(crate) authority: std::string::String,
|
||||
/// Freeze authority used by freeze and thaw scenarios.
|
||||
pub(crate) freeze_authority: std::string::String,
|
||||
/// Exact raw amount represented as a decimal string.
|
||||
pub(crate) amount_raw: std::string::String,
|
||||
/// Expected mint decimals carried by checked instructions.
|
||||
pub(crate) decimals: u8,
|
||||
/// Whether the transaction should be signed and submitted after simulation.
|
||||
pub(crate) submit: bool,
|
||||
/// Explicit operator confirmation for signed submission.
|
||||
pub(crate) operator_confirmed: bool,
|
||||
/// Whether post-execution core and decode outputs should be force-replayed.
|
||||
pub(crate) force_post_validation_replay: bool,
|
||||
}
|
||||
|
||||
/// UI-safe result of one public Token-2022 Devnet orchestration.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_spl_token_2022/DemoExecutionSplToken2022SummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionSplToken2022SummaryPayload {
|
||||
/// Stable scenario identifier.
|
||||
pub(crate) scenario_id: std::string::String,
|
||||
/// Stable operation code.
|
||||
pub(crate) operation: std::string::String,
|
||||
/// Profile used by the orchestration.
|
||||
pub(crate) profile_name: std::string::String,
|
||||
/// Classified cluster code.
|
||||
pub(crate) cluster: std::string::String,
|
||||
/// Genesis hash returned by the endpoint.
|
||||
pub(crate) genesis_hash: std::string::String,
|
||||
/// Source wallet public key.
|
||||
pub(crate) wallet_public_key: std::string::String,
|
||||
/// Exact wallet balance rendered without JSON integer precision loss.
|
||||
pub(crate) balance_lamports: std::string::String,
|
||||
/// Estimated fee rendered without JSON integer precision loss.
|
||||
pub(crate) fee_lamports: std::option::Option<std::string::String>,
|
||||
/// Whether exact simulation succeeded.
|
||||
pub(crate) simulation_success: bool,
|
||||
/// Runtime simulation error when present.
|
||||
pub(crate) simulation_error: std::option::Option<std::string::String>,
|
||||
/// Submitted transaction signature.
|
||||
pub(crate) transaction_signature: std::option::Option<std::string::String>,
|
||||
/// Terminal confirmation status.
|
||||
pub(crate) confirmation_status: std::option::Option<std::string::String>,
|
||||
/// Whether canonical hydration was validated.
|
||||
pub(crate) canonical_inserted: bool,
|
||||
/// Whether core extraction was validated.
|
||||
pub(crate) core_extracted: bool,
|
||||
/// Whether Token-2022 decode replay was validated.
|
||||
pub(crate) decode_replayed: bool,
|
||||
/// Number of materialized rows for the submitted instruction.
|
||||
pub(crate) materialization_count: u32,
|
||||
/// Whether the second replay produced no failure or new output.
|
||||
pub(crate) idempotence_validated: bool,
|
||||
/// Pretty JSON representation of the exact execution plan.
|
||||
pub(crate) plan_json: std::string::String,
|
||||
/// Pretty JSON representation of stateful preflight.
|
||||
pub(crate) preflight_json: std::string::String,
|
||||
/// Pretty JSON representation of the exact simulation result.
|
||||
pub(crate) simulation_json: std::string::String,
|
||||
/// Pretty JSON representation of confirmation and replay diagnostics.
|
||||
pub(crate) diagnostics_json: std::string::String,
|
||||
}
|
||||
|
||||
pub(crate) fn operation_from_request(
|
||||
request: &crate::DemoExecutionSplToken2022Request,
|
||||
) -> std::result::Result<kb_executor_spl_token_2022::SplToken2022Operation, std::string::String> {
|
||||
let authority = kb_executor_spl_token_2022::SplTokenAuthority {
|
||||
authority: kb_model::Pubkey(request.authority.trim().to_string()),
|
||||
multisig_signers: std::vec::Vec::new(),
|
||||
};
|
||||
let freeze_authority = kb_executor_spl_token_2022::SplTokenAuthority {
|
||||
authority: kb_model::Pubkey(request.freeze_authority.trim().to_string()),
|
||||
multisig_signers: std::vec::Vec::new(),
|
||||
};
|
||||
let value = match request.scenario_id.trim() {
|
||||
"token_2022_mint_to_checked" => {
|
||||
kb_executor_spl_token_2022::SplTokenSingleOperation::MintToChecked {
|
||||
mint: kb_model::Pubkey(request.mint.trim().to_string()),
|
||||
destination: kb_model::Pubkey(request.source.trim().to_string()),
|
||||
authority,
|
||||
amount: kb_executor_spl_token_2022::SplTokenAmount(
|
||||
request.amount_raw.trim().to_string(),
|
||||
),
|
||||
decimals: request.decimals,
|
||||
}
|
||||
},
|
||||
"token_2022_transfer_checked" => {
|
||||
kb_executor_spl_token_2022::SplTokenSingleOperation::TransferChecked {
|
||||
source: kb_model::Pubkey(request.source.trim().to_string()),
|
||||
mint: kb_model::Pubkey(request.mint.trim().to_string()),
|
||||
destination: kb_model::Pubkey(request.destination.trim().to_string()),
|
||||
authority,
|
||||
amount: kb_executor_spl_token_2022::SplTokenAmount(
|
||||
request.amount_raw.trim().to_string(),
|
||||
),
|
||||
decimals: request.decimals,
|
||||
}
|
||||
},
|
||||
"token_2022_approve_checked" => {
|
||||
kb_executor_spl_token_2022::SplTokenSingleOperation::ApproveChecked {
|
||||
source: kb_model::Pubkey(request.source.trim().to_string()),
|
||||
mint: kb_model::Pubkey(request.mint.trim().to_string()),
|
||||
delegate: kb_model::Pubkey(request.delegate.trim().to_string()),
|
||||
authority,
|
||||
amount: kb_executor_spl_token_2022::SplTokenAmount(
|
||||
request.amount_raw.trim().to_string(),
|
||||
),
|
||||
decimals: request.decimals,
|
||||
}
|
||||
},
|
||||
"token_2022_revoke" => kb_executor_spl_token_2022::SplTokenSingleOperation::Revoke {
|
||||
source: kb_model::Pubkey(request.source.trim().to_string()),
|
||||
authority,
|
||||
},
|
||||
"token_2022_burn_checked" => {
|
||||
kb_executor_spl_token_2022::SplTokenSingleOperation::BurnChecked {
|
||||
source: kb_model::Pubkey(request.source.trim().to_string()),
|
||||
mint: kb_model::Pubkey(request.mint.trim().to_string()),
|
||||
authority,
|
||||
amount: kb_executor_spl_token_2022::SplTokenAmount(
|
||||
request.amount_raw.trim().to_string(),
|
||||
),
|
||||
decimals: request.decimals,
|
||||
}
|
||||
},
|
||||
"token_2022_freeze_account" => {
|
||||
kb_executor_spl_token_2022::SplTokenSingleOperation::FreezeAccount {
|
||||
account: kb_model::Pubkey(request.source.trim().to_string()),
|
||||
mint: kb_model::Pubkey(request.mint.trim().to_string()),
|
||||
authority: freeze_authority,
|
||||
}
|
||||
},
|
||||
"token_2022_thaw_account" => {
|
||||
kb_executor_spl_token_2022::SplTokenSingleOperation::ThawAccount {
|
||||
account: kb_model::Pubkey(request.source.trim().to_string()),
|
||||
mint: kb_model::Pubkey(request.mint.trim().to_string()),
|
||||
authority: freeze_authority,
|
||||
}
|
||||
},
|
||||
"token_2022_close_destination" => {
|
||||
kb_executor_spl_token_2022::SplTokenSingleOperation::CloseAccount {
|
||||
account: kb_model::Pubkey(request.source.trim().to_string()),
|
||||
destination: kb_model::Pubkey(request.destination.trim().to_string()),
|
||||
authority,
|
||||
}
|
||||
},
|
||||
other => {
|
||||
return std::result::Result::Err(format!(
|
||||
"unsupported public Token-2022 Devnet scenario {other}"
|
||||
));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(
|
||||
kb_executor_spl_token_2022::SplToken2022Operation::Instruction {
|
||||
value: std::boxed::Box::new(value),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn demo_spl_token_2022_summary_payload(
|
||||
scenario_id: std::string::String,
|
||||
operation: std::string::String,
|
||||
summary: kb_pipeline::DevnetSplToken2022ExecutionSummary,
|
||||
) -> crate::DemoExecutionSplToken2022SummaryPayload {
|
||||
let plan_json = crate::pretty_json(&summary.plan);
|
||||
let preflight_json = crate::pretty_json(&summary.stateful_preflight);
|
||||
let simulation_json = crate::pretty_json(&summary.simulation);
|
||||
let transaction_signature =
|
||||
summary.send_result.as_ref().map(|value| return value.signature.0.clone());
|
||||
let confirmation_status = summary.confirmation.as_ref().map(|value| {
|
||||
return crate::confirmation_status_code(value.status);
|
||||
});
|
||||
let post_execution = summary.post_execution.as_ref();
|
||||
let canonical_inserted = post_execution.is_some_and(|value| return value.canonical_inserted);
|
||||
let core_extracted = post_execution.is_some_and(|value| return value.core_extracted);
|
||||
let decode_replayed = post_execution.is_some_and(|value| return value.decode_replayed);
|
||||
let idempotence_validated = summary.idempotence_replay.as_ref().is_some_and(|replay| {
|
||||
return replay.failed_inputs == 0
|
||||
&& !replay.cancelled
|
||||
&& replay.processors.iter().all(|processor| {
|
||||
return processor.failed == 0
|
||||
&& processor.materialized_outputs == 0
|
||||
&& processor.materialization_refused == 0;
|
||||
});
|
||||
});
|
||||
let materialization_count = match u32::try_from(summary.materializations.len()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => u32::MAX,
|
||||
};
|
||||
let diagnostics = serde_json::json!({
|
||||
"confirmation": summary.confirmation,
|
||||
"postExecution": summary.post_execution,
|
||||
"backfill": summary.backfill.as_ref().map(backfill_summary_json),
|
||||
"coreExtraction": summary.core_extraction.as_ref().map(core_extraction_summary_json),
|
||||
"decodeReplay": summary.decode_replay.as_ref().map(decode_replay_summary_json),
|
||||
"idempotenceReplay": summary.idempotence_replay.as_ref().map(decode_replay_summary_json),
|
||||
"materializations": summary.materializations
|
||||
});
|
||||
return crate::DemoExecutionSplToken2022SummaryPayload {
|
||||
scenario_id,
|
||||
operation,
|
||||
profile_name: summary.profile_name,
|
||||
cluster: crate::execution_cluster_code(summary.cluster),
|
||||
genesis_hash: summary.genesis_hash,
|
||||
wallet_public_key: summary.wallet.public_key,
|
||||
balance_lamports: summary.balance_lamports.to_string(),
|
||||
fee_lamports: summary.fee.fee_lamports.map(|value| return value.to_string()),
|
||||
simulation_success: summary.simulation.success,
|
||||
simulation_error: summary.simulation.error,
|
||||
transaction_signature,
|
||||
confirmation_status,
|
||||
canonical_inserted,
|
||||
core_extracted,
|
||||
decode_replayed,
|
||||
materialization_count,
|
||||
idempotence_validated,
|
||||
plan_json,
|
||||
preflight_json,
|
||||
simulation_json,
|
||||
diagnostics_json: crate::pretty_json(&diagnostics),
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn parse_fixture(
|
||||
contents: &str,
|
||||
) -> std::collections::BTreeMap<std::string::String, std::string::String> {
|
||||
let mut values = std::collections::BTreeMap::new();
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
let assignment = match trimmed.strip_prefix("export ") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let (name, raw_value) = match assignment.split_once('=') {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
let value = raw_value.trim().trim_matches('\'').trim_matches('"').to_string();
|
||||
values.insert(name.trim().to_string(), value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
fn backfill_summary_json(summary: &kb_pipeline::BackfillSummary) -> serde_json::Value {
|
||||
return serde_json::json!({
|
||||
"captureSessionId": summary.capture_session_id,
|
||||
"filterCode": summary.filter_code,
|
||||
"role": summary.role,
|
||||
"provider": summary.provider,
|
||||
"endpointCode": summary.endpoint_code,
|
||||
"pagesFetched": summary.pages_fetched,
|
||||
"candidatesSelected": summary.candidates_selected,
|
||||
"candidatesStarted": summary.candidates_started,
|
||||
"candidatesCompleted": summary.candidates_completed,
|
||||
"candidatesCancelled": summary.candidates_cancelled,
|
||||
"candidatesNotStarted": summary.candidates_not_started,
|
||||
"transactionsReceived": summary.transactions_received,
|
||||
"canonicalInserted": summary.canonical_inserted,
|
||||
"canonicalSkipped": summary.canonical_skipped,
|
||||
"existingSkipped": summary.existing_skipped,
|
||||
"missing": summary.missing,
|
||||
"failed": summary.failed,
|
||||
"observationsInserted": summary.observations_inserted,
|
||||
"attempts": summary.attempts,
|
||||
"cancelled": summary.cancelled,
|
||||
"resumeBeforeSignature": summary.resume_before_signature,
|
||||
"startedAt": summary.started_at,
|
||||
"finishedAt": summary.finished_at
|
||||
});
|
||||
}
|
||||
|
||||
fn core_extraction_summary_json(summary: &kb_pipeline::CoreExtractionSummary) -> serde_json::Value {
|
||||
return serde_json::json!({
|
||||
"processorVersion": summary.processor_version,
|
||||
"selected": summary.selected,
|
||||
"started": summary.started,
|
||||
"completed": summary.completed,
|
||||
"skipped": summary.skipped,
|
||||
"extracted": summary.extracted,
|
||||
"failed": summary.failed,
|
||||
"cancelledCandidates": summary.cancelled_candidates,
|
||||
"notStarted": summary.not_started,
|
||||
"cancelled": summary.cancelled,
|
||||
"startedAt": summary.started_at,
|
||||
"finishedAt": summary.finished_at
|
||||
});
|
||||
}
|
||||
|
||||
fn decode_replay_summary_json(summary: &kb_pipeline::DecodeReplaySummary) -> serde_json::Value {
|
||||
let processors = summary
|
||||
.processors
|
||||
.iter()
|
||||
.map(|processor| {
|
||||
return serde_json::json!({
|
||||
"name": processor.processor_name,
|
||||
"version": processor.processor_version,
|
||||
"dispatched": processor.dispatched,
|
||||
"skipped": processor.skipped,
|
||||
"decoded": processor.decoded,
|
||||
"ignored": processor.ignored,
|
||||
"unsupported": processor.unsupported,
|
||||
"failed": processor.failed,
|
||||
"materializedOutputs": processor.materialized_outputs,
|
||||
"materializationRefused": processor.materialization_refused
|
||||
});
|
||||
})
|
||||
.collect::<std::vec::Vec<serde_json::Value>>();
|
||||
return serde_json::json!({
|
||||
"campaignId": summary.campaign_id,
|
||||
"pipelineVersion": summary.pipeline_version,
|
||||
"selected": summary.selected,
|
||||
"started": summary.started,
|
||||
"completed": summary.completed,
|
||||
"unmatched": summary.unmatched,
|
||||
"notStarted": summary.not_started,
|
||||
"failedInputs": summary.failed_inputs,
|
||||
"cancelled": summary.cancelled,
|
||||
"processors": processors,
|
||||
"startedAt": summary.started_at,
|
||||
"finishedAt": summary.finished_at
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn request(scenario_id: &str) -> crate::DemoExecutionSplToken2022Request {
|
||||
return crate::DemoExecutionSplToken2022Request {
|
||||
profile_name: "local_devnet".to_string(),
|
||||
scenario_id: scenario_id.to_string(),
|
||||
source: "source".to_string(),
|
||||
mint: "mint".to_string(),
|
||||
destination: "destination".to_string(),
|
||||
delegate: "delegate".to_string(),
|
||||
authority: "authority".to_string(),
|
||||
freeze_authority: "freeze".to_string(),
|
||||
amount_raw: "1".to_string(),
|
||||
decimals: 9,
|
||||
submit: false,
|
||||
operator_confirmed: false,
|
||||
force_post_validation_replay: true,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_public_scenario_builds_one_typed_operation() {
|
||||
for scenario in [
|
||||
"token_2022_mint_to_checked",
|
||||
"token_2022_transfer_checked",
|
||||
"token_2022_approve_checked",
|
||||
"token_2022_revoke",
|
||||
"token_2022_burn_checked",
|
||||
"token_2022_freeze_account",
|
||||
"token_2022_thaw_account",
|
||||
"token_2022_close_destination",
|
||||
] {
|
||||
assert!(crate::operation_from_request(&request(scenario)).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// file: kb_app_demo/src/demo_sql_common.rs
|
||||
// version: 7
|
||||
|
||||
//! 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/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<i64>,
|
||||
/// Minimum slot when available.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) min_slot: std::option::Option<i64>,
|
||||
/// Maximum slot when available.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) max_slot: std::option::Option<i64>,
|
||||
/// Latest insertion timestamp rendered by PostgreSQL when available.
|
||||
pub(crate) latest_created_at: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Connects to PostgreSQL without reapplying schemas initialized at application startup.
|
||||
pub(crate) async fn connect_postgres_store(
|
||||
profile: &kb_config::ProfileConfig,
|
||||
) -> std::result::Result<kb_store_pg::PostgresStore, std::string::String> {
|
||||
if !profile.database.enabled {
|
||||
return std::result::Result::Err(std::string::String::from(
|
||||
"database configuration is disabled in the active profile",
|
||||
));
|
||||
}
|
||||
let options_result = kb_store_pg::PostgresStoreOptions::from_profile_config(profile);
|
||||
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_pg::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_pg::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_pg::PostgresTableDiagnostics],
|
||||
) -> std::vec::Vec<crate::DemoSqlTableSnapshot> {
|
||||
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 = kb_store_pg::PostgresStoreOptions::from_profile_config(profile);
|
||||
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_pg::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_pg::PostgresTableDiagnostics],
|
||||
after_tables: &[kb_store_pg::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_pg::PostgresTableDiagnostics],
|
||||
table_name: &str,
|
||||
) -> std::option::Option<bool> {
|
||||
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<T: std::fmt::Debug>(value: T) -> std::string::String {
|
||||
return format!("{value:?}");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// file: kb_app_demo/src/demo_sql_diag.rs
|
||||
// version: 2
|
||||
|
||||
//! 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/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<std::string::String>,
|
||||
/// PostgreSQL health status.
|
||||
pub(crate) health_status: std::string::String,
|
||||
/// PostgreSQL health message.
|
||||
pub(crate) health_message: std::option::Option<std::string::String>,
|
||||
/// Migration status.
|
||||
pub(crate) migration_status: std::string::String,
|
||||
/// Migration diagnostic message.
|
||||
pub(crate) migration_message: std::option::Option<std::string::String>,
|
||||
/// PostgreSQL server version when available.
|
||||
pub(crate) server_version: std::option::Option<std::string::String>,
|
||||
/// Known raw/core table diagnostics.
|
||||
pub(crate) tables: std::vec::Vec<crate::DemoSqlTableSnapshot>,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// file: kb_app_demo/src/demo_sql_pg_core.rs
|
||||
// version: 3
|
||||
|
||||
//! 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/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<crate::DemoSqlTableSnapshot>,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// file: kb_app_demo/src/demo_sql_pg_raw.rs
|
||||
// version: 4
|
||||
|
||||
//! 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/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<crate::DemoSqlTableSnapshot>,
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
// file: kb_app_demo/src/demo_sql_replay_candidates.rs
|
||||
// version: 6
|
||||
|
||||
//! 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/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/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<crate::DemoSqlReplayKnownProgramOption>,
|
||||
}
|
||||
|
||||
/// 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/demo_sql_replay_candidates/DemoSqlReplayTransactionRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoSqlReplayTransactionRequest {
|
||||
/// Optional partial signature search.
|
||||
pub(crate) signature_contains: std::option::Option<std::string::String>,
|
||||
/// Optional inclusive minimum slot.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) min_slot: std::option::Option<u64>,
|
||||
/// Optional inclusive maximum slot.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) max_slot: std::option::Option<u64>,
|
||||
/// Optional raw processing state.
|
||||
pub(crate) raw_processing_state: std::option::Option<std::string::String>,
|
||||
/// Optional latest ledger status.
|
||||
pub(crate) ledger_status: std::option::Option<std::string::String>,
|
||||
/// Optional exact program id.
|
||||
pub(crate) program_id: std::option::Option<std::string::String>,
|
||||
/// 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<std::string::String>,
|
||||
/// Optional exact entity value.
|
||||
pub(crate) entity_value: std::option::Option<std::string::String>,
|
||||
/// 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/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<bool>,
|
||||
/// Latest core extraction ledger status.
|
||||
pub(crate) ledger_status: std::string::String,
|
||||
/// Latest processor version when available.
|
||||
pub(crate) processor_version: std::option::Option<std::string::String>,
|
||||
/// 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/demo_sql_replay_candidates/DemoSqlReplayProgramRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoSqlReplayProgramRequest {
|
||||
/// Optional partial program id search.
|
||||
pub(crate) program_id_contains: std::option::Option<std::string::String>,
|
||||
/// 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/demo_sql_replay_candidates/DemoSqlReplayProgramRow.ts"
|
||||
)]
|
||||
pub(crate) struct DemoSqlReplayProgramRow {
|
||||
/// Optional stable code from `kb_program_ids`.
|
||||
pub(crate) program_code: std::option::Option<std::string::String>,
|
||||
/// 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/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<std::string::String>,
|
||||
/// 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/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_pg::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_pg::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_pg::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"), 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<std::path::PathBuf, std::string::String> {
|
||||
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<kb_store_pg::PostgresReplayProgramScope, std::string::String> {
|
||||
return match code {
|
||||
"any" => std::result::Result::Ok(kb_store_pg::PostgresReplayProgramScope::Any),
|
||||
"outer" => std::result::Result::Ok(kb_store_pg::PostgresReplayProgramScope::Outer),
|
||||
"inner" => std::result::Result::Ok(kb_store_pg::PostgresReplayProgramScope::Inner),
|
||||
"logs" => std::result::Result::Ok(kb_store_pg::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<kb_store_pg::PostgresReplayEntityKind>,
|
||||
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<kb_store_pg::PostgresReplayEntityKind, std::string::String> {
|
||||
return match code {
|
||||
"mint" => std::result::Result::Ok(kb_store_pg::PostgresReplayEntityKind::Mint),
|
||||
"owner" => std::result::Result::Ok(kb_store_pg::PostgresReplayEntityKind::Owner),
|
||||
"account_key" => std::result::Result::Ok(kb_store_pg::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_pg::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");
|
||||
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_pg::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"));
|
||||
}
|
||||
}
|
||||
869
migration/khadhroony-bot2-reference/kb_app_demo/src/demo_ws.rs
Normal file
869
migration/khadhroony-bot2-reference/kb_app_demo/src/demo_ws.rs
Normal file
@@ -0,0 +1,869 @@
|
||||
// file: kb_app_demo/src/demo_ws.rs
|
||||
// version: 13
|
||||
|
||||
//! Standard Solana WebSocket demo commands backed by `kb_rpc::WsSession`.
|
||||
|
||||
use tauri::Emitter; // rust-rules: trait-import
|
||||
use tauri::Manager; // rust-rules: trait-import
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// One selectable role shown by the WebSocket demo.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_ws/DemoWsRoleOption.ts"
|
||||
)]
|
||||
pub(crate) struct DemoWsRoleOption {
|
||||
/// Endpoint role code.
|
||||
pub(crate) role: std::string::String,
|
||||
/// Request kinds accepted by this role.
|
||||
pub(crate) request_kinds: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// One selectable WebSocket JSON-RPC method shown by the WebSocket demo.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_ws/DemoWsMethodOption.ts"
|
||||
)]
|
||||
pub(crate) struct DemoWsMethodOption {
|
||||
/// JSON-RPC subscribe method name.
|
||||
pub(crate) method: std::string::String,
|
||||
/// Matching unsubscribe method name.
|
||||
pub(crate) unsubscribe_method: std::string::String,
|
||||
/// Derived request kind used for endpoint routing.
|
||||
pub(crate) request_kind: std::string::String,
|
||||
/// Human-readable method label.
|
||||
pub(crate) label: std::string::String,
|
||||
/// Whether this method needs the target field.
|
||||
pub(crate) requires_target: bool,
|
||||
/// Whether this method needs the filter JSON field.
|
||||
pub(crate) requires_filter_json: bool,
|
||||
/// Whether this method supports an optional configuration object.
|
||||
pub(crate) supports_config_json: bool,
|
||||
}
|
||||
|
||||
/// WebSocket demo options derived from configuration and the standard registry.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_ws/DemoWsOptionsPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoWsOptionsPayload {
|
||||
/// Selectable roles.
|
||||
pub(crate) roles: std::vec::Vec<crate::DemoWsRoleOption>,
|
||||
/// Selectable methods.
|
||||
pub(crate) methods: std::vec::Vec<crate::DemoWsMethodOption>,
|
||||
}
|
||||
|
||||
/// Request payload for one WebSocket subscription demo command.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_ws/DemoWsRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoWsRequest {
|
||||
/// Required endpoint role used by the WebSocket pool.
|
||||
pub(crate) role: std::string::String,
|
||||
/// JSON-RPC WebSocket subscribe method name.
|
||||
pub(crate) method: std::string::String,
|
||||
/// Optional target string used by account/program/signature subscriptions.
|
||||
pub(crate) target: std::option::Option<std::string::String>,
|
||||
/// Optional JSON filter string used by logsSubscribe or blockSubscribe.
|
||||
pub(crate) filter_json: std::option::Option<std::string::String>,
|
||||
/// Optional JSON configuration string.
|
||||
pub(crate) config_json: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
/// Response payload for one WebSocket subscription response.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_ws/DemoWsExecutionPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoWsExecutionPayload {
|
||||
/// Selected endpoint name.
|
||||
pub(crate) endpoint_name: std::string::String,
|
||||
/// Selected provider name.
|
||||
pub(crate) provider: std::string::String,
|
||||
/// Selected endpoint URL.
|
||||
pub(crate) endpoint_url: std::string::String,
|
||||
/// Required role used by the selection.
|
||||
pub(crate) role: std::string::String,
|
||||
/// JSON-RPC method name.
|
||||
pub(crate) method: std::string::String,
|
||||
/// Derived request kind.
|
||||
pub(crate) request_kind: std::string::String,
|
||||
/// Parsed response kind.
|
||||
pub(crate) response_kind: std::string::String,
|
||||
/// Remote subscription id returned by the node.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) subscription_id: std::option::Option<u64>,
|
||||
/// Pretty JSON response text.
|
||||
pub(crate) response_json: std::string::String,
|
||||
}
|
||||
|
||||
/// Current WebSocket demo session status.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_ws/DemoWsStatusPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoWsStatusPayload {
|
||||
/// Whether a session is currently connected.
|
||||
pub(crate) connected: bool,
|
||||
/// Selected endpoint name.
|
||||
pub(crate) endpoint_name: std::option::Option<std::string::String>,
|
||||
/// Selected endpoint URL.
|
||||
pub(crate) endpoint_url: std::option::Option<std::string::String>,
|
||||
/// Last subscription method.
|
||||
pub(crate) method: std::option::Option<std::string::String>,
|
||||
/// Last unsubscribe method.
|
||||
pub(crate) unsubscribe_method: std::option::Option<std::string::String>,
|
||||
/// Last remote subscription id.
|
||||
#[ts(type = "number | null")]
|
||||
pub(crate) subscription_id: std::option::Option<u64>,
|
||||
/// Number of active subscriptions on the current connection.
|
||||
pub(crate) subscription_count: u32,
|
||||
/// Active subscriptions kept by the session.
|
||||
pub(crate) subscriptions: std::vec::Vec<crate::DemoWsSubscriptionStatusPayload>,
|
||||
}
|
||||
|
||||
/// One active WebSocket subscription shown by the demo UI.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_ws/DemoWsSubscriptionStatusPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoWsSubscriptionStatusPayload {
|
||||
/// Remote subscription id.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) subscription_id: u64,
|
||||
/// Subscribe method used to create this subscription.
|
||||
pub(crate) method: std::string::String,
|
||||
/// Matching unsubscribe method.
|
||||
pub(crate) unsubscribe_method: std::string::String,
|
||||
}
|
||||
|
||||
/// Request payload for one explicit WebSocket unsubscribe command.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_ws/DemoWsUnsubscribeRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoWsUnsubscribeRequest {
|
||||
/// Remote subscription id to unsubscribe.
|
||||
#[ts(type = "number")]
|
||||
pub(crate) subscription_id: u64,
|
||||
}
|
||||
|
||||
/// WebSocket event payload emitted to the demo window.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/demo_ws/DemoWsMessagePayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoWsMessagePayload {
|
||||
/// Message kind.
|
||||
pub(crate) kind: std::string::String,
|
||||
/// Pretty JSON or text payload.
|
||||
pub(crate) payload_json: std::string::String,
|
||||
}
|
||||
|
||||
const DEMO_WS_UI_RATE_WINDOW_MS: u64 = 1000;
|
||||
const DEMO_WS_UI_MAX_MESSAGES_PER_WINDOW: u32 = 12;
|
||||
const DEMO_WS_UI_MAX_PAYLOAD_CHARS: usize = 8_000;
|
||||
const DEMO_WS_RECONNECT_ATTEMPTS: u32 = 3;
|
||||
const DEMO_WS_RECONNECT_INITIAL_DELAY_MS: u64 = 250;
|
||||
const DEMO_WS_RECONNECT_MAX_DELAY_MS: u64 = 2_000;
|
||||
|
||||
struct DemoWsUiRateLimiter {
|
||||
window_started_at: std::time::Instant,
|
||||
emitted_in_window: u32,
|
||||
dropped_in_window: u32,
|
||||
}
|
||||
|
||||
impl DemoWsUiRateLimiter {
|
||||
fn new() -> Self {
|
||||
return Self {
|
||||
window_started_at: std::time::Instant::now(),
|
||||
emitted_in_window: 0,
|
||||
dropped_in_window: 0,
|
||||
};
|
||||
}
|
||||
|
||||
fn should_emit(&mut self, app_handle: &tauri::AppHandle) -> bool {
|
||||
let now = std::time::Instant::now();
|
||||
if now.duration_since(self.window_started_at)
|
||||
>= std::time::Duration::from_millis(DEMO_WS_UI_RATE_WINDOW_MS)
|
||||
{
|
||||
if self.dropped_in_window > 0 {
|
||||
emit_demo_ws_message(
|
||||
app_handle,
|
||||
"throttled",
|
||||
format!(
|
||||
"{} WebSocket notification(s) masquée(s) pendant la dernière fenêtre UI.",
|
||||
self.dropped_in_window
|
||||
),
|
||||
);
|
||||
}
|
||||
self.window_started_at = now;
|
||||
self.emitted_in_window = 0;
|
||||
self.dropped_in_window = 0;
|
||||
}
|
||||
if self.emitted_in_window >= DEMO_WS_UI_MAX_MESSAGES_PER_WINDOW {
|
||||
self.dropped_in_window = self.dropped_in_window.saturating_add(1);
|
||||
return false;
|
||||
}
|
||||
self.emitted_in_window = self.emitted_in_window.saturating_add(1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn demo_ws_status_inner(
|
||||
state: &crate::AppState,
|
||||
) -> std::result::Result<crate::DemoWsStatusPayload, std::string::String> {
|
||||
let session = {
|
||||
let guard = state.demo_ws_session().lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
return match session {
|
||||
std::option::Option::Some(session) => {
|
||||
std::result::Result::Ok(status_from_snapshot(session.snapshot().await))
|
||||
},
|
||||
std::option::Option::None => std::result::Result::Ok(disconnected_status()),
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) async fn demo_ws_connect_inner(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: &crate::AppState,
|
||||
request: crate::DemoWsRequest,
|
||||
) -> std::result::Result<crate::DemoWsExecutionPayload, std::string::String> {
|
||||
let role = request.role.trim().to_string();
|
||||
if role.is_empty() {
|
||||
return std::result::Result::Err("demo WebSocket role must not be empty".to_string());
|
||||
}
|
||||
let method = request.method.trim().to_string();
|
||||
if method.is_empty() {
|
||||
return std::result::Result::Err("demo WebSocket method must not be empty".to_string());
|
||||
}
|
||||
let standard_request = match build_standard_ws_request(
|
||||
&method,
|
||||
request.target,
|
||||
request.filter_json,
|
||||
request.config_json,
|
||||
) {
|
||||
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) {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let session = match ensure_demo_ws_session(&app_handle, state, selected_client.clone()).await {
|
||||
std::result::Result::Ok(session) => session,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let acknowledgement = match session.subscribe(standard_request).await {
|
||||
std::result::Result::Ok(acknowledgement) => acknowledgement,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let response_value = match acknowledgement.response.to_value() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let response_json = match serde_json::to_string_pretty(&response_value) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
emit_demo_ws_status(&app_handle, session.snapshot().await);
|
||||
return std::result::Result::Ok(crate::DemoWsExecutionPayload {
|
||||
endpoint_name: selected_client.endpoint_name().to_string(),
|
||||
provider: selected_client.provider().to_string(),
|
||||
endpoint_url: selected_client.endpoint_url().to_string(),
|
||||
role,
|
||||
method: method.clone(),
|
||||
request_kind: kb_rpc::request_kind_from_method(&method),
|
||||
response_kind: acknowledgement.response.kind_name().to_string(),
|
||||
subscription_id: acknowledgement.subscription.remote_subscription_id,
|
||||
response_json,
|
||||
});
|
||||
}
|
||||
|
||||
async fn ensure_demo_ws_session(
|
||||
app_handle: &tauri::AppHandle,
|
||||
state: &crate::AppState,
|
||||
selected_client: kb_rpc::WsClient,
|
||||
) -> std::result::Result<std::sync::Arc<kb_rpc::WsSession>, std::string::String> {
|
||||
let existing = {
|
||||
let guard = state.demo_ws_session().lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
if let std::option::Option::Some(session) = existing {
|
||||
let snapshot = session.snapshot().await;
|
||||
if snapshot.state != kb_rpc::WsSessionState::Disconnected {
|
||||
if snapshot.endpoint_url != selected_client.endpoint_url() {
|
||||
return std::result::Result::Err(format!(
|
||||
"demo WebSocket session already uses endpoint '{}'; disconnect before selecting '{}'",
|
||||
snapshot.endpoint_name,
|
||||
selected_client.endpoint_name()
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(session);
|
||||
}
|
||||
}
|
||||
let reconnect_policy = if selected_client.endpoint_config().auto_reconnect {
|
||||
match kb_rpc::WsReconnectPolicy::bounded(
|
||||
DEMO_WS_RECONNECT_ATTEMPTS,
|
||||
DEMO_WS_RECONNECT_INITIAL_DELAY_MS,
|
||||
DEMO_WS_RECONNECT_MAX_DELAY_MS,
|
||||
) {
|
||||
std::result::Result::Ok(policy) => policy,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
}
|
||||
} else {
|
||||
kb_rpc::WsReconnectPolicy::disabled()
|
||||
};
|
||||
let capabilities =
|
||||
kb_rpc::StandardWsCapabilities::from_endpoint(selected_client.endpoint_config());
|
||||
let session =
|
||||
match kb_rpc::WsSession::connect(selected_client, capabilities, reconnect_policy).await {
|
||||
std::result::Result::Ok(session) => std::sync::Arc::new(session),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
spawn_demo_ws_event_bridge(app_handle.clone(), session.clone());
|
||||
{
|
||||
let mut guard = state.demo_ws_session().lock().await;
|
||||
*guard = std::option::Option::Some(session.clone());
|
||||
}
|
||||
return std::result::Result::Ok(session);
|
||||
}
|
||||
|
||||
pub(crate) async fn demo_ws_unsubscribe_inner(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: &crate::AppState,
|
||||
remote_subscription_id: u64,
|
||||
) -> std::result::Result<crate::DemoWsStatusPayload, std::string::String> {
|
||||
let session = {
|
||||
let guard = state.demo_ws_session().lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
let session = match session {
|
||||
std::option::Option::Some(session) => session,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err("demo WebSocket session is not connected".to_string());
|
||||
},
|
||||
};
|
||||
let unsubscribe_result = session.unsubscribe(remote_subscription_id).await;
|
||||
if let std::result::Result::Err(error) = unsubscribe_result {
|
||||
return std::result::Result::Err(error.to_string());
|
||||
}
|
||||
let snapshot = session.snapshot().await;
|
||||
emit_demo_ws_status(&app_handle, snapshot.clone());
|
||||
return std::result::Result::Ok(status_from_snapshot(snapshot));
|
||||
}
|
||||
|
||||
pub(crate) async fn demo_ws_disconnect_inner(
|
||||
state: &crate::AppState,
|
||||
) -> std::result::Result<crate::DemoWsStatusPayload, std::string::String> {
|
||||
let session = {
|
||||
let mut guard = state.demo_ws_session().lock().await;
|
||||
guard.take()
|
||||
};
|
||||
if let std::option::Option::Some(session) = session {
|
||||
if let std::result::Result::Err(error) = session.disconnect().await {
|
||||
return std::result::Result::Err(error.to_string());
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(disconnected_status());
|
||||
}
|
||||
|
||||
/// Disconnects the demo WebSocket session from application lifecycle hooks.
|
||||
pub(crate) async fn disconnect_demo_ws_app_state(state: &crate::AppState, _wait_for_relay: bool) {
|
||||
let disconnect_result = demo_ws_disconnect_inner(state).await;
|
||||
if let std::result::Result::Err(error) = disconnect_result {
|
||||
tracing::debug!(target: crate::TRACING_TARGET, "demo ws lifecycle disconnect failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_demo_ws_event_bridge(
|
||||
app_handle: tauri::AppHandle,
|
||||
session: std::sync::Arc<kb_rpc::WsSession>,
|
||||
) {
|
||||
let mut receiver = session.subscribe_events();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut rate_limiter = DemoWsUiRateLimiter::new();
|
||||
loop {
|
||||
let event = receiver.recv().await;
|
||||
let event = match event {
|
||||
std::result::Result::Ok(event) => event,
|
||||
std::result::Result::Err(tokio::sync::broadcast::error::RecvError::Lagged(
|
||||
count,
|
||||
)) => {
|
||||
emit_demo_ws_message(
|
||||
&app_handle,
|
||||
"lagged",
|
||||
format!("{count} événement(s) WebSocket interne(s) perdu(s)"),
|
||||
);
|
||||
continue;
|
||||
},
|
||||
std::result::Result::Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
};
|
||||
match event {
|
||||
kb_rpc::WsSessionEvent::Notification { notification, .. } => {
|
||||
if rate_limiter.should_emit(&app_handle) {
|
||||
let payload = match serde_json::to_string_pretty(¬ification) {
|
||||
std::result::Result::Ok(payload) => payload,
|
||||
std::result::Result::Err(error) => {
|
||||
format!("cannot serialize typed WebSocket notification: {error}")
|
||||
},
|
||||
};
|
||||
emit_demo_ws_message(
|
||||
&app_handle,
|
||||
"notification",
|
||||
truncate_payload(payload),
|
||||
);
|
||||
}
|
||||
},
|
||||
kb_rpc::WsSessionEvent::Diagnostic { code, message } => {
|
||||
emit_demo_ws_message(&app_handle, &code, truncate_payload(message));
|
||||
},
|
||||
kb_rpc::WsSessionEvent::Reconnecting { attempt, maximum_attempts } => {
|
||||
emit_demo_ws_message(
|
||||
&app_handle,
|
||||
"reconnecting",
|
||||
format!("tentative {attempt}/{maximum_attempts}"),
|
||||
);
|
||||
emit_demo_ws_status(&app_handle, session.snapshot().await);
|
||||
},
|
||||
kb_rpc::WsSessionEvent::Reconnected { reconnect_count } => {
|
||||
emit_demo_ws_message(
|
||||
&app_handle,
|
||||
"reconnected",
|
||||
format!("reconnexion numéro {reconnect_count}"),
|
||||
);
|
||||
emit_demo_ws_status(&app_handle, session.snapshot().await);
|
||||
},
|
||||
kb_rpc::WsSessionEvent::Connected
|
||||
| kb_rpc::WsSessionEvent::SubscriptionAdded(_)
|
||||
| kb_rpc::WsSessionEvent::SubscriptionRemapped { .. }
|
||||
| kb_rpc::WsSessionEvent::SubscriptionRemoved(_) => {
|
||||
emit_demo_ws_status(&app_handle, session.snapshot().await);
|
||||
},
|
||||
kb_rpc::WsSessionEvent::Disconnected => {
|
||||
emit_demo_ws_status(&app_handle, session.snapshot().await);
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_demo_ws_status(app_handle: &tauri::AppHandle, snapshot: kb_rpc::WsSessionSnapshot) {
|
||||
let window = match app_handle.get_webview_window("demo_ws") {
|
||||
std::option::Option::Some(window) => window,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let status = status_from_snapshot(snapshot);
|
||||
if let std::result::Result::Err(error) = window.emit("demo-ws-status", status) {
|
||||
tracing::debug!(target: crate::TRACING_TARGET, "cannot emit demo ws status: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_demo_ws_message(
|
||||
app_handle: &tauri::AppHandle,
|
||||
kind: &str,
|
||||
payload_json: std::string::String,
|
||||
) {
|
||||
let window = match app_handle.get_webview_window("demo_ws") {
|
||||
std::option::Option::Some(window) => window,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
if let std::result::Result::Err(error) = window.emit(
|
||||
"demo-ws-message",
|
||||
crate::DemoWsMessagePayload { kind: kind.to_string(), payload_json },
|
||||
) {
|
||||
tracing::debug!(target: crate::TRACING_TARGET, "cannot emit demo ws message: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn status_from_snapshot(snapshot: kb_rpc::WsSessionSnapshot) -> crate::DemoWsStatusPayload {
|
||||
let mut subscriptions = std::vec::Vec::new();
|
||||
for subscription in &snapshot.subscriptions {
|
||||
if let std::option::Option::Some(remote_subscription_id) =
|
||||
subscription.remote_subscription_id
|
||||
{
|
||||
subscriptions.push(crate::DemoWsSubscriptionStatusPayload {
|
||||
subscription_id: remote_subscription_id,
|
||||
method: subscription.subscribe_method.clone(),
|
||||
unsubscribe_method: subscription.unsubscribe_method.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let (method, unsubscribe_method, subscription_id) = match subscriptions.last() {
|
||||
std::option::Option::Some(subscription) => (
|
||||
std::option::Option::Some(subscription.method.clone()),
|
||||
std::option::Option::Some(subscription.unsubscribe_method.clone()),
|
||||
std::option::Option::Some(subscription.subscription_id),
|
||||
),
|
||||
std::option::Option::None => {
|
||||
(std::option::Option::None, std::option::Option::None, std::option::Option::None)
|
||||
},
|
||||
};
|
||||
return crate::DemoWsStatusPayload {
|
||||
connected: snapshot.state != kb_rpc::WsSessionState::Disconnected,
|
||||
endpoint_name: std::option::Option::Some(snapshot.endpoint_name),
|
||||
endpoint_url: std::option::Option::Some(snapshot.endpoint_url),
|
||||
method,
|
||||
unsubscribe_method,
|
||||
subscription_id,
|
||||
subscription_count: match u32::try_from(subscriptions.len()) {
|
||||
std::result::Result::Ok(count) => count,
|
||||
std::result::Result::Err(_) => u32::MAX,
|
||||
},
|
||||
subscriptions,
|
||||
};
|
||||
}
|
||||
|
||||
fn disconnected_status() -> crate::DemoWsStatusPayload {
|
||||
return crate::DemoWsStatusPayload {
|
||||
connected: false,
|
||||
endpoint_name: std::option::Option::None,
|
||||
endpoint_url: std::option::Option::None,
|
||||
method: std::option::Option::None,
|
||||
unsubscribe_method: std::option::Option::None,
|
||||
subscription_id: std::option::Option::None,
|
||||
subscription_count: 0,
|
||||
subscriptions: std::vec::Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn build_ws_role_options(
|
||||
snapshots: std::vec::Vec<kb_rpc::WsPoolClientSnapshot>,
|
||||
) -> std::vec::Vec<crate::DemoWsRoleOption> {
|
||||
let mut roles = std::collections::BTreeMap::<
|
||||
std::string::String,
|
||||
std::collections::BTreeSet<std::string::String>,
|
||||
>::new();
|
||||
for snapshot in snapshots {
|
||||
for role in snapshot.roles {
|
||||
if !role.enabled {
|
||||
continue;
|
||||
}
|
||||
let role_entry = roles.entry(role.role).or_default();
|
||||
for request_kind in role.request_kinds {
|
||||
role_entry.insert(request_kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut options = std::vec::Vec::new();
|
||||
for (role, request_kinds) in roles {
|
||||
options.push(crate::DemoWsRoleOption {
|
||||
role,
|
||||
request_kinds: request_kinds.into_iter().collect(),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
pub(crate) fn build_ws_method_options() -> std::vec::Vec<crate::DemoWsMethodOption> {
|
||||
let labels = std::collections::BTreeMap::from([
|
||||
("accountSubscribe", ("Compte", true, false, true)),
|
||||
("blockSubscribe", ("Bloc", false, true, true)),
|
||||
("logsSubscribe", ("Logs", false, true, true)),
|
||||
("programSubscribe", ("Programme", true, false, true)),
|
||||
("rootSubscribe", ("Roots", false, false, false)),
|
||||
("signatureSubscribe", ("Signature", true, false, true)),
|
||||
("slotSubscribe", ("Slots", false, false, false)),
|
||||
("slotsUpdatesSubscribe", ("Mises à jour slots", false, false, false)),
|
||||
("voteSubscribe", ("Votes", false, false, false)),
|
||||
]);
|
||||
let mut options = std::vec::Vec::new();
|
||||
for specification in &kb_rpc::STANDARD_WS_SUBSCRIPTIONS {
|
||||
let metadata = labels.get(specification.subscribe_method);
|
||||
let (label, requires_target, requires_filter_json, supports_config_json) = match metadata {
|
||||
std::option::Option::Some(metadata) => *metadata,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
options.push(crate::DemoWsMethodOption {
|
||||
method: specification.subscribe_method.to_string(),
|
||||
unsubscribe_method: specification.unsubscribe_method.to_string(),
|
||||
request_kind: kb_rpc::request_kind_from_method(specification.subscribe_method),
|
||||
label: label.to_string(),
|
||||
requires_target,
|
||||
requires_filter_json,
|
||||
supports_config_json,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
fn build_standard_ws_request(
|
||||
method: &str,
|
||||
target: std::option::Option<std::string::String>,
|
||||
filter_json: std::option::Option<std::string::String>,
|
||||
config_json: std::option::Option<std::string::String>,
|
||||
) -> std::result::Result<kb_rpc::StandardWsRequest, std::string::String> {
|
||||
return match method {
|
||||
"accountSubscribe" => {
|
||||
let target = match required_target(target, method) {
|
||||
std::result::Result::Ok(target) => target,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let config = match parse_optional_json_as::<kb_rpc::WsAccountSubscribeConfig>(
|
||||
config_json,
|
||||
"configJson",
|
||||
) {
|
||||
std::result::Result::Ok(config) => config,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
reject_present(filter_json, "filterJson", method)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
std::result::Result::Ok(kb_rpc::StandardWsRequest::Account(
|
||||
kb_rpc::AccountSubscribeRequest { pubkey: target, config },
|
||||
))
|
||||
},
|
||||
"blockSubscribe" => {
|
||||
if let std::result::Result::Err(error) = reject_present(target, "target", method) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let filter =
|
||||
match parse_required_json_as::<kb_rpc::WsBlockFilter>(filter_json, "filterJson") {
|
||||
std::result::Result::Ok(filter) => filter,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let config = match parse_optional_json_as::<kb_rpc::WsBlockSubscribeConfig>(
|
||||
config_json,
|
||||
"configJson",
|
||||
) {
|
||||
std::result::Result::Ok(config) => config,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
std::result::Result::Ok(kb_rpc::StandardWsRequest::Block(
|
||||
kb_rpc::BlockSubscribeRequest { filter, config },
|
||||
))
|
||||
},
|
||||
"logsSubscribe" => {
|
||||
if let std::result::Result::Err(error) = reject_present(target, "target", method) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let filter =
|
||||
match parse_required_json_as::<kb_rpc::WsLogsFilter>(filter_json, "filterJson") {
|
||||
std::result::Result::Ok(filter) => filter,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let config = match parse_optional_json_as::<kb_rpc::WsLogsSubscribeConfig>(
|
||||
config_json,
|
||||
"configJson",
|
||||
) {
|
||||
std::result::Result::Ok(config) => config,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
std::result::Result::Ok(kb_rpc::StandardWsRequest::Logs(kb_rpc::LogsSubscribeRequest {
|
||||
filter,
|
||||
config,
|
||||
}))
|
||||
},
|
||||
"programSubscribe" => {
|
||||
let target = match required_target(target, method) {
|
||||
std::result::Result::Ok(target) => target,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
reject_present(filter_json, "filterJson", method)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let config = match parse_optional_json_as::<kb_rpc::WsProgramSubscribeConfig>(
|
||||
config_json,
|
||||
"configJson",
|
||||
) {
|
||||
std::result::Result::Ok(config) => config,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
std::result::Result::Ok(kb_rpc::StandardWsRequest::Program(
|
||||
kb_rpc::ProgramSubscribeRequest { program_id: target, config },
|
||||
))
|
||||
},
|
||||
"signatureSubscribe" => {
|
||||
let target = match required_target(target, method) {
|
||||
std::result::Result::Ok(target) => target,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
reject_present(filter_json, "filterJson", method)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let config = match parse_optional_json_as::<kb_rpc::WsSignatureSubscribeConfig>(
|
||||
config_json,
|
||||
"configJson",
|
||||
) {
|
||||
std::result::Result::Ok(config) => config,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
std::result::Result::Ok(kb_rpc::StandardWsRequest::Signature(
|
||||
kb_rpc::SignatureSubscribeRequest { signature: target, config },
|
||||
))
|
||||
},
|
||||
"rootSubscribe" => {
|
||||
if let std::result::Result::Err(error) =
|
||||
reject_parameterless_inputs(target, filter_json, config_json, method)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
std::result::Result::Ok(kb_rpc::StandardWsRequest::Root(kb_rpc::RootSubscribeRequest))
|
||||
},
|
||||
"slotSubscribe" => {
|
||||
if let std::result::Result::Err(error) =
|
||||
reject_parameterless_inputs(target, filter_json, config_json, method)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
std::result::Result::Ok(kb_rpc::StandardWsRequest::Slot(kb_rpc::SlotSubscribeRequest))
|
||||
},
|
||||
"slotsUpdatesSubscribe" => {
|
||||
if let std::result::Result::Err(error) =
|
||||
reject_parameterless_inputs(target, filter_json, config_json, method)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
std::result::Result::Ok(kb_rpc::StandardWsRequest::SlotsUpdates(
|
||||
kb_rpc::SlotsUpdatesSubscribeRequest,
|
||||
))
|
||||
},
|
||||
"voteSubscribe" => {
|
||||
if let std::result::Result::Err(error) =
|
||||
reject_parameterless_inputs(target, filter_json, config_json, method)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
std::result::Result::Ok(kb_rpc::StandardWsRequest::Vote(kb_rpc::VoteSubscribeRequest))
|
||||
},
|
||||
_ => std::result::Result::Err(format!("unsupported standard WebSocket method '{method}'")),
|
||||
};
|
||||
}
|
||||
|
||||
fn required_target(
|
||||
target: std::option::Option<std::string::String>,
|
||||
method: &str,
|
||||
) -> std::result::Result<std::string::String, std::string::String> {
|
||||
let target = match target {
|
||||
std::option::Option::Some(target) => target.trim().to_string(),
|
||||
std::option::Option::None => std::string::String::new(),
|
||||
};
|
||||
if target.is_empty() {
|
||||
return std::result::Result::Err(format!("method '{method}' requires target"));
|
||||
}
|
||||
return std::result::Result::Ok(target);
|
||||
}
|
||||
|
||||
fn reject_parameterless_inputs(
|
||||
target: std::option::Option<std::string::String>,
|
||||
filter_json: std::option::Option<std::string::String>,
|
||||
config_json: std::option::Option<std::string::String>,
|
||||
method: &str,
|
||||
) -> std::result::Result<(), std::string::String> {
|
||||
if let std::result::Result::Err(error) = reject_present(target, "target", method) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = reject_present(filter_json, "filterJson", method) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = reject_present(config_json, "configJson", method) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn reject_present(
|
||||
value: std::option::Option<std::string::String>,
|
||||
field: &str,
|
||||
method: &str,
|
||||
) -> std::result::Result<(), std::string::String> {
|
||||
if let std::option::Option::Some(value) = value {
|
||||
if !value.trim().is_empty() {
|
||||
return std::result::Result::Err(format!("method '{method}' does not accept {field}"));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn parse_required_json_as<ValueType: serde::de::DeserializeOwned>(
|
||||
text: std::option::Option<std::string::String>,
|
||||
field: &str,
|
||||
) -> std::result::Result<ValueType, std::string::String> {
|
||||
let text = match text {
|
||||
std::option::Option::Some(text) if !text.trim().is_empty() => text,
|
||||
_ => return std::result::Result::Err(format!("{field} is required")),
|
||||
};
|
||||
return match serde_json::from_str::<ValueType>(&text) {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(format!("invalid {field}: {error}"))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn parse_optional_json_as<ValueType: serde::de::DeserializeOwned>(
|
||||
text: std::option::Option<std::string::String>,
|
||||
field: &str,
|
||||
) -> std::result::Result<std::option::Option<ValueType>, std::string::String> {
|
||||
let text = match text {
|
||||
std::option::Option::Some(text) if !text.trim().is_empty() => text,
|
||||
_ => return std::result::Result::Ok(std::option::Option::None),
|
||||
};
|
||||
return match serde_json::from_str::<ValueType>(&text) {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(format!("invalid {field}: {error}"))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn truncate_payload(payload: std::string::String) -> std::string::String {
|
||||
if payload.chars().count() <= DEMO_WS_UI_MAX_PAYLOAD_CHARS {
|
||||
return payload;
|
||||
}
|
||||
let mut truncated = payload
|
||||
.chars()
|
||||
.take(DEMO_WS_UI_MAX_PAYLOAD_CHARS)
|
||||
.collect::<std::string::String>();
|
||||
truncated.push_str("\n… payload tronqué côté Rust …");
|
||||
return truncated;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
#[test]
|
||||
fn tauri_numeric_bindings_use_json_compatible_numbers() {
|
||||
let config = ts_rs::Config::default();
|
||||
let declarations = [
|
||||
<crate::DemoWsExecutionPayload as TS>::decl(&config),
|
||||
<crate::DemoWsStatusPayload as TS>::decl(&config),
|
||||
<crate::DemoWsSubscriptionStatusPayload as TS>::decl(&config),
|
||||
<crate::DemoWsUnsubscribeRequest as TS>::decl(&config),
|
||||
<crate::DemoBackfillProgressPayload as TS>::decl(&config),
|
||||
<crate::DemoBackfillSummaryPayload as TS>::decl(&config),
|
||||
<crate::DemoSqlTableSnapshot as TS>::decl(&config),
|
||||
];
|
||||
for declaration in declarations {
|
||||
assert!(!declaration.contains("bigint"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// file: kb_app_demo/src/frontend_log.rs
|
||||
// version: 10
|
||||
|
||||
//! Frontend logging bridge commands for Tauri WebView scripts.
|
||||
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// Log payload sent by frontend scripts when a typed tracing target is required.
|
||||
#[derive(Clone, Debug, serde::Deserialize, TS)]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo/frontend_log/FrontendLogPayload.ts"
|
||||
)]
|
||||
pub(crate) struct FrontendLogPayload {
|
||||
/// Lowercase tracing level: `trace`, `debug`, `info`, `warn`, or `error`.
|
||||
pub(crate) level: std::string::String,
|
||||
/// Explicit tracing target used for route-based filtering.
|
||||
pub(crate) target: std::string::String,
|
||||
/// Message rendered by the frontend before forwarding it to Rust.
|
||||
pub(crate) message: std::string::String,
|
||||
}
|
||||
|
||||
pub(crate) fn emit_trace(target: &str, message: &str) {
|
||||
tracing::trace!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "frontend_log",
|
||||
frontend_target = target,
|
||||
"{}",
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_debug(target: &str, message: &str) {
|
||||
tracing::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "frontend_log",
|
||||
frontend_target = target,
|
||||
"{}",
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_info(target: &str, message: &str) {
|
||||
tracing::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "frontend_log",
|
||||
frontend_target = target,
|
||||
"{}",
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_warn(target: &str, message: &str) {
|
||||
tracing::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "frontend_log",
|
||||
frontend_target = target,
|
||||
"{}",
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_error(target: &str, message: &str) {
|
||||
tracing::error!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "frontend_log",
|
||||
frontend_target = target,
|
||||
"{}",
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_frontend_target(target: &str) -> std::string::String {
|
||||
let trimmed = target.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "kb_app_demo.frontend".to_string();
|
||||
}
|
||||
if trimmed == "kb_app_demo.frontend.main" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed == "kb_app_demo.frontend.demo_config" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed == "kb_app_demo.frontend.demo_http" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed == "kb_app_demo.frontend.demo_ws" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed == "kb_app_demo.frontend.demo_backfill" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed == "kb_app_demo.frontend.demo_core_extraction" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed == "kb_app_demo.frontend.demo_decode_replay" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed == "kb_app_demo.frontend.demo_execution_solana_core" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed == "kb_app_demo.frontend.demo_execution_spl" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed == "kb_app_demo.frontend.demo_sql_replay_candidates" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed == "kb_app_demo.frontend.splash" {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
return "kb_app_demo.frontend".to_string();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn normalize_frontend_target_uses_default_for_empty_target() {
|
||||
let normalized = crate::normalize_frontend_target(" ");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_frontend_target_keeps_main_target() {
|
||||
let normalized = crate::normalize_frontend_target("kb_app_demo.frontend.main");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend.main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_frontend_target_keeps_demo_config_target() {
|
||||
let normalized = crate::normalize_frontend_target("kb_app_demo.frontend.demo_config");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend.demo_config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_frontend_target_keeps_demo_http_target() {
|
||||
let normalized = crate::normalize_frontend_target("kb_app_demo.frontend.demo_http");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend.demo_http");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_frontend_target_keeps_demo_ws_target() {
|
||||
let normalized = crate::normalize_frontend_target("kb_app_demo.frontend.demo_ws");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend.demo_ws");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_frontend_target_keeps_demo_backfill_target() {
|
||||
let normalized = crate::normalize_frontend_target("kb_app_demo.frontend.demo_backfill");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend.demo_backfill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_frontend_target_keeps_demo_core_extraction_target() {
|
||||
let normalized =
|
||||
crate::normalize_frontend_target("kb_app_demo.frontend.demo_core_extraction");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend.demo_core_extraction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_frontend_target_keeps_demo_decode_replay_target() {
|
||||
let normalized =
|
||||
crate::normalize_frontend_target("kb_app_demo.frontend.demo_decode_replay");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend.demo_decode_replay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_frontend_target_keeps_demo_execution_solana_core_target() {
|
||||
let normalized =
|
||||
crate::normalize_frontend_target("kb_app_demo.frontend.demo_execution_solana_core");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend.demo_execution_solana_core");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_frontend_target_keeps_demo_sql_replay_candidates_target() {
|
||||
let normalized =
|
||||
crate::normalize_frontend_target("kb_app_demo.frontend.demo_sql_replay_candidates");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend.demo_sql_replay_candidates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_frontend_target_keeps_splash_target() {
|
||||
let normalized = crate::normalize_frontend_target("kb_app_demo.frontend.splash");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend.splash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_frontend_target_rejects_unknown_dynamic_targets() {
|
||||
let normalized = crate::normalize_frontend_target("other.frontend.target");
|
||||
assert_eq!(normalized, "kb_app_demo.frontend");
|
||||
}
|
||||
}
|
||||
338
migration/khadhroony-bot2-reference/kb_app_demo/src/lib.rs
Normal file
338
migration/khadhroony-bot2-reference/kb_app_demo/src/lib.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
// file: kb_app_demo/src/lib.rs
|
||||
// version: 30
|
||||
|
||||
//! Tauri application demo library for `khadhroony-bot2`.
|
||||
//!
|
||||
//! This crate loads the shared configuration, initializes workspace logging and
|
||||
//! exposes focused demo windows for early runtime validation.
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod app_state;
|
||||
mod constants;
|
||||
mod demo_backfill;
|
||||
mod demo_config;
|
||||
mod demo_core_extraction;
|
||||
mod demo_decode_replay;
|
||||
mod demo_execution_solana_core;
|
||||
mod demo_execution_spl;
|
||||
mod demo_http;
|
||||
mod demo_spl_ata;
|
||||
mod demo_spl_token;
|
||||
mod demo_spl_token_2022;
|
||||
mod demo_sql_common;
|
||||
mod demo_sql_diag;
|
||||
mod demo_sql_pg_core;
|
||||
mod demo_sql_pg_raw;
|
||||
mod demo_sql_replay_candidates;
|
||||
mod demo_ws;
|
||||
mod frontend_log;
|
||||
mod main_window;
|
||||
mod splash;
|
||||
mod tauri;
|
||||
|
||||
/// Shared state managed by Tauri for the demo application.
|
||||
pub(crate) use crate::app_state::AppState;
|
||||
/// Canonical tracing target for this crate.
|
||||
pub(crate) use crate::constants::TRACING_TARGET;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_backfill::DemoBackfillObserver;
|
||||
/// Initial options shown by the backfill demo.
|
||||
pub(crate) use crate::demo_backfill::DemoBackfillOptionsPayload;
|
||||
/// One progress event emitted to the backfill window.
|
||||
pub(crate) use crate::demo_backfill::DemoBackfillProgressPayload;
|
||||
/// UI request for one bounded backfill campaign.
|
||||
pub(crate) use crate::demo_backfill::DemoBackfillRequest;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_backfill::DemoBackfillRunGuard;
|
||||
/// Final UI-safe summary for one backfill campaign.
|
||||
pub(crate) use crate::demo_backfill::DemoBackfillSummaryPayload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_backfill::build_demo_backfill_pipeline_request;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_backfill::build_role_options;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_backfill::demo_backfill_summary_payload;
|
||||
/// Serializable payload shown by the configuration demo page.
|
||||
pub(crate) use crate::demo_config::DemoConfigPayload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_core_extraction::DemoCoreExtractionObserver;
|
||||
/// Initial options shown by the core extraction demo.
|
||||
pub(crate) use crate::demo_core_extraction::DemoCoreExtractionOptionsPayload;
|
||||
/// One progress event emitted to the core extraction window.
|
||||
pub(crate) use crate::demo_core_extraction::DemoCoreExtractionProgressPayload;
|
||||
/// UI request for one bounded core extraction campaign.
|
||||
pub(crate) use crate::demo_core_extraction::DemoCoreExtractionRequest;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_core_extraction::DemoCoreExtractionRunGuard;
|
||||
/// Final UI-safe summary for one extraction campaign.
|
||||
pub(crate) use crate::demo_core_extraction::DemoCoreExtractionSummaryPayload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_core_extraction::build_demo_core_extraction_pipeline_request;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_core_extraction::demo_core_extraction_summary_payload;
|
||||
/// One aggregated coverage row returned to the UI.
|
||||
pub(crate) use crate::demo_decode_replay::DemoDecodeCoverageSummaryPayload;
|
||||
/// Read-only decode, ledger and coverage diagnostics.
|
||||
pub(crate) use crate::demo_decode_replay::DemoDecodeDiagnosticsPayload;
|
||||
/// One processor counter row returned to the UI.
|
||||
pub(crate) use crate::demo_decode_replay::DemoDecodeProcessorSummaryPayload;
|
||||
/// One decoder selectable by the decode replay demo.
|
||||
pub(crate) use crate::demo_decode_replay::DemoDecodeReplayDecoderOption;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_decode_replay::DemoDecodeReplayObserver;
|
||||
/// Initial options shown by the contextual decode replay demo.
|
||||
pub(crate) use crate::demo_decode_replay::DemoDecodeReplayOptionsPayload;
|
||||
/// UI request for one bounded contextual decode replay campaign.
|
||||
pub(crate) use crate::demo_decode_replay::DemoDecodeReplayRequest;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_decode_replay::DemoDecodeReplayRunGuard;
|
||||
/// Final UI-safe summary for one contextual decode replay campaign.
|
||||
pub(crate) use crate::demo_decode_replay::DemoDecodeReplaySummaryPayload;
|
||||
/// Bounded read request for committed transaction annotations.
|
||||
pub(crate) use crate::demo_decode_replay::DemoTransactionAnnotationRequest;
|
||||
/// UI-safe committed transaction annotation row.
|
||||
pub(crate) use crate::demo_decode_replay::DemoTransactionAnnotationRow;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_decode_replay::annotation_payload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_decode_replay::available_decoders;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_decode_replay::available_materializers;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_decode_replay::build_demo_decode_replay_pipeline_request;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_decode_replay::coverage_payload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_decode_replay::demo_decode_replay_summary_payload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_decode_replay::optional_line_count;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_decode_replay::register_active_campaign;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_decode_replay::text_sample;
|
||||
/// Request sent by the Memo v4 Devnet execution panel.
|
||||
pub(crate) use crate::demo_execution_solana_core::DemoExecutionMemoRequest;
|
||||
/// UI-safe result of one Memo v4 Devnet execution orchestration.
|
||||
pub(crate) use crate::demo_execution_solana_core::DemoExecutionMemoSummaryPayload;
|
||||
/// Public key generated for a disposable Devnet recipient.
|
||||
pub(crate) use crate::demo_execution_solana_core::DemoExecutionSolanaCoreGeneratedRecipientPayload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_execution_solana_core::DemoExecutionSolanaCoreObserver;
|
||||
/// Initial options shown by the Solana Core execution demo.
|
||||
pub(crate) use crate::demo_execution_solana_core::DemoExecutionSolanaCoreOptionsPayload;
|
||||
/// One Devnet execution profile exposed to the demo window.
|
||||
pub(crate) use crate::demo_execution_solana_core::DemoExecutionSolanaCoreProfileOption;
|
||||
/// Progress event emitted during execution and post-validation.
|
||||
pub(crate) use crate::demo_execution_solana_core::DemoExecutionSolanaCoreProgressPayload;
|
||||
/// Request sent by the execution demo.
|
||||
pub(crate) use crate::demo_execution_solana_core::DemoExecutionSolanaCoreRequest;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_execution_solana_core::DemoExecutionSolanaCoreRunGuard;
|
||||
/// UI-safe result of one execution orchestration.
|
||||
pub(crate) use crate::demo_execution_solana_core::DemoExecutionSolanaCoreSummaryPayload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_execution_solana_core::confirmation_status_code;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_execution_solana_core::demo_execution_solana_core_memo_summary_payload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_execution_solana_core::demo_execution_solana_core_summary_payload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_execution_solana_core::devnet_profile_options;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_execution_solana_core::execution_cluster_code;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_execution_solana_core::pretty_json;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_execution_solana_core::select_devnet_profile;
|
||||
/// Frontend payload for one independent Devnet SPL validation scenario.
|
||||
pub(crate) use crate::demo_execution_spl::DevnetSplValidationScenarioPayload;
|
||||
/// Response payload for one HTTP JSON-RPC demo execution.
|
||||
pub(crate) use crate::demo_http::DemoHttpExecutionPayload;
|
||||
/// One selectable HTTP JSON-RPC method shown by the HTTP demo.
|
||||
pub(crate) use crate::demo_http::DemoHttpMethodOption;
|
||||
/// HTTP demo options derived from configuration and local method presets.
|
||||
pub(crate) use crate::demo_http::DemoHttpOptionsPayload;
|
||||
/// Request payload for one HTTP JSON-RPC demo execution.
|
||||
pub(crate) use crate::demo_http::DemoHttpRequest;
|
||||
/// One selectable role shown by the HTTP demo.
|
||||
pub(crate) use crate::demo_http::DemoHttpRoleOption;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_http::build_http_method_options;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_http::build_http_role_options;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_http::demo_http_execute_request_inner;
|
||||
/// Request for one representative ATA creation on Devnet.
|
||||
pub(crate) use crate::demo_spl_ata::DemoExecutionSplAtaRequest;
|
||||
/// UI-safe ATA execution result.
|
||||
pub(crate) use crate::demo_spl_ata::DemoExecutionSplAtaSummaryPayload;
|
||||
/// Readonly payer, wallet, Token Program and derived ATA values.
|
||||
pub(crate) use crate::demo_spl_ata::DemoSplAtaDerivationPayload;
|
||||
/// Request used to derive the representative wallet ATA before execution.
|
||||
pub(crate) use crate::demo_spl_ata::DemoSplAtaDerivationRequest;
|
||||
/// Bounded ATA lifecycle journal request.
|
||||
pub(crate) use crate::demo_spl_ata::DemoSplAtaJournalRequest;
|
||||
/// One UI-safe ATA lifecycle journal row.
|
||||
pub(crate) use crate::demo_spl_ata::DemoSplAtaJournalRow;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_spl_ata::demo_spl_ata_summary_payload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_spl_ata::derive_ata;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_spl_ata::journal_matches;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_spl_ata::journal_row;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_spl_ata::load_profile_wallet;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_spl_ata::parse_token_program;
|
||||
/// Request for one checked classic SPL Token transfer on Devnet.
|
||||
pub(crate) use crate::demo_spl_token::DemoExecutionSplTokenRequest;
|
||||
/// UI-safe result of one checked SPL Token transfer orchestration.
|
||||
pub(crate) use crate::demo_spl_token::DemoExecutionSplTokenSummaryPayload;
|
||||
/// Bounded exact filters for the classic SPL Token materialized journal.
|
||||
pub(crate) use crate::demo_spl_token::DemoSplTokenJournalRequest;
|
||||
/// UI-safe materialized classic SPL Token journal row.
|
||||
pub(crate) use crate::demo_spl_token::DemoSplTokenJournalRow;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_spl_token::demo_spl_token_journal_row;
|
||||
/// Executes one checked SPL Token simulation or explicitly authorized Devnet submission.
|
||||
pub(crate) use crate::demo_spl_token::demo_spl_token_summary_payload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_spl_token::payload_matches;
|
||||
/// Loads a bounded, typed journal of committed classic SPL Token projections.
|
||||
pub(crate) use crate::demo_spl_token::validate_journal_request;
|
||||
/// Request for one independent public Token-2022 Devnet scenario.
|
||||
pub(crate) use crate::demo_spl_token_2022::DemoExecutionSplToken2022Request;
|
||||
/// UI-safe result of one public Token-2022 Devnet orchestration.
|
||||
pub(crate) use crate::demo_spl_token_2022::DemoExecutionSplToken2022SummaryPayload;
|
||||
/// Public values loaded from one persisted Token-2022 validation fixture.
|
||||
pub(crate) use crate::demo_spl_token_2022::DemoSplToken2022FixturePayload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_spl_token_2022::demo_spl_token_2022_summary_payload;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_spl_token_2022::operation_from_request;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_spl_token_2022::parse_fixture;
|
||||
/// UI-safe table diagnostics shown by SQL demo windows.
|
||||
pub(crate) use crate::demo_sql_common::DemoSqlTableSnapshot;
|
||||
/// Connects to PostgreSQL without reapplying schemas initialized at application startup.
|
||||
pub(crate) use crate::demo_sql_common::connect_postgres_store;
|
||||
/// Formats debug enums for UI display.
|
||||
pub(crate) use crate::demo_sql_common::debug_status;
|
||||
/// Initializes PostgreSQL raw, core, decode and materialization schemas during Tauri startup.
|
||||
pub(crate) use crate::demo_sql_common::initialize_postgres_schema_for_startup;
|
||||
/// Opens or focuses a SQL demo window.
|
||||
pub(crate) use crate::demo_sql_common::open_sql_demo_window;
|
||||
/// Converts store table diagnostics to the UI payload shape.
|
||||
pub(crate) use crate::demo_sql_common::table_snapshot_from_pg;
|
||||
/// Converts many table diagnostics to UI payloads.
|
||||
pub(crate) use crate::demo_sql_common::table_snapshots_from_pg;
|
||||
/// Complete SQL diagnostic payload shown by `demo_sql_diag`.
|
||||
pub(crate) use crate::demo_sql_diag::DemoSqlDiagPayload;
|
||||
/// PostgreSQL core store payload shown by `demo_sql_pg_core`.
|
||||
pub(crate) use crate::demo_sql_pg_core::DemoSqlPgCorePayload;
|
||||
/// PostgreSQL canonical acquisition payload shown by `demo_sql_pg_raw`.
|
||||
pub(crate) use crate::demo_sql_pg_raw::DemoSqlPgRawPayload;
|
||||
/// UI request for bounded core entity summaries.
|
||||
pub(crate) use crate::demo_sql_replay_candidates::DemoSqlReplayEntityRequest;
|
||||
/// Aggregated core entity row shown by the replay candidate browser.
|
||||
pub(crate) use crate::demo_sql_replay_candidates::DemoSqlReplayEntityRow;
|
||||
/// One program identifier exposed by the runtime `kb_program_ids` registry.
|
||||
pub(crate) use crate::demo_sql_replay_candidates::DemoSqlReplayKnownProgramOption;
|
||||
/// Static options for the replay candidate browser.
|
||||
pub(crate) use crate::demo_sql_replay_candidates::DemoSqlReplayOptionsPayload;
|
||||
/// UI request for bounded program summaries.
|
||||
pub(crate) use crate::demo_sql_replay_candidates::DemoSqlReplayProgramRequest;
|
||||
/// Aggregated program row shown by the replay candidate browser.
|
||||
pub(crate) use crate::demo_sql_replay_candidates::DemoSqlReplayProgramRow;
|
||||
/// UI request for bounded transaction replay candidates.
|
||||
pub(crate) use crate::demo_sql_replay_candidates::DemoSqlReplayTransactionRequest;
|
||||
/// One transaction row shown by the replay candidate browser.
|
||||
pub(crate) use crate::demo_sql_replay_candidates::DemoSqlReplayTransactionRow;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_sql_replay_candidates::available_csv_export_path;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_sql_replay_candidates::csv_export_directory_from_current_dir;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_sql_replay_candidates::entity_kind_from_code;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_sql_replay_candidates::entity_row_from_pg;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_sql_replay_candidates::optional_entity_kind_from_code;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_sql_replay_candidates::program_row_from_pg;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_sql_replay_candidates::program_scope_from_code;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_sql_replay_candidates::transaction_row_from_pg;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_sql_replay_candidates::validated_csv_file_name;
|
||||
/// Response payload for one WebSocket subscription response.
|
||||
pub(crate) use crate::demo_ws::DemoWsExecutionPayload;
|
||||
/// WebSocket event payload emitted to the demo window.
|
||||
pub(crate) use crate::demo_ws::DemoWsMessagePayload;
|
||||
/// One selectable WebSocket JSON-RPC method shown by the WebSocket demo.
|
||||
pub(crate) use crate::demo_ws::DemoWsMethodOption;
|
||||
/// WebSocket demo options derived from configuration and the standard registry.
|
||||
pub(crate) use crate::demo_ws::DemoWsOptionsPayload;
|
||||
/// Request payload for one WebSocket subscription demo command.
|
||||
pub(crate) use crate::demo_ws::DemoWsRequest;
|
||||
/// One selectable role shown by the WebSocket demo.
|
||||
pub(crate) use crate::demo_ws::DemoWsRoleOption;
|
||||
/// Current WebSocket demo session status.
|
||||
pub(crate) use crate::demo_ws::DemoWsStatusPayload;
|
||||
/// One active WebSocket subscription shown by the demo UI.
|
||||
pub(crate) use crate::demo_ws::DemoWsSubscriptionStatusPayload;
|
||||
/// Request payload for one explicit WebSocket unsubscribe command.
|
||||
pub(crate) use crate::demo_ws::DemoWsUnsubscribeRequest;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_ws::build_ws_method_options;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_ws::build_ws_role_options;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_ws::demo_ws_connect_inner;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_ws::demo_ws_disconnect_inner;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_ws::demo_ws_status_inner;
|
||||
/// ????
|
||||
pub(crate) use crate::demo_ws::demo_ws_unsubscribe_inner;
|
||||
/// Disconnects the demo WebSocket session from application lifecycle hooks.
|
||||
pub(crate) use crate::demo_ws::disconnect_demo_ws_app_state;
|
||||
/// Log payload sent by frontend scripts when a typed tracing target is required.
|
||||
pub(crate) use crate::frontend_log::FrontendLogPayload;
|
||||
/// ????
|
||||
pub(crate) use crate::frontend_log::emit_debug;
|
||||
/// ????
|
||||
pub(crate) use crate::frontend_log::emit_error;
|
||||
/// ????
|
||||
pub(crate) use crate::frontend_log::emit_info;
|
||||
/// ????
|
||||
pub(crate) use crate::frontend_log::emit_trace;
|
||||
/// ????
|
||||
pub(crate) use crate::frontend_log::emit_warn;
|
||||
/// ????
|
||||
pub(crate) use crate::frontend_log::normalize_frontend_target;
|
||||
/// ????
|
||||
pub(crate) use crate::main_window::strip_readme_metadata_comments;
|
||||
/// Resolves the workspace root directory used by the demo application.
|
||||
pub(crate) use crate::main_window::workspace_root_dir;
|
||||
/// Delay after the fade-out command before destroying the splash window.
|
||||
pub(crate) use crate::splash::SPLASH_CLOSE_WAIT_MS;
|
||||
/// Splash fade-in and fade-out animation duration.
|
||||
pub(crate) use crate::splash::SPLASH_FADE_MS;
|
||||
/// Minimum startup splash duration before fade-out starts.
|
||||
pub(crate) use crate::splash::SPLASH_MINIMUM_MS;
|
||||
/// Command payload sent from Rust to the splash frontend.
|
||||
pub(crate) use crate::splash::SplashOrder;
|
||||
/// Emits a splash-screen order to the splash WebView.
|
||||
pub(crate) use crate::splash::emit_splash_order;
|
||||
/// Waits until the minimum splash duration has elapsed.
|
||||
pub(crate) use crate::splash::wait_until_minimum;
|
||||
|
||||
/// Runs the Tauri application.
|
||||
pub use crate::tauri::run;
|
||||
43
migration/khadhroony-bot2-reference/kb_app_demo/src/main.rs
Normal file
43
migration/khadhroony-bot2-reference/kb_app_demo/src/main.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
// file: kb_app_demo/src/main.rs
|
||||
// version: 6
|
||||
|
||||
//! Binary entry point for the `kb_app_demo` crate.
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use fs2::FileExt; // rust-rules: trait-import
|
||||
|
||||
/// Entrypoint of the kb app binary.
|
||||
#[tokio::main]
|
||||
async fn main() -> std::process::ExitCode {
|
||||
let mut lock_path = std::env::temp_dir();
|
||||
lock_path.push("com_khadhroony_bot2_kb_app_demo.lock");
|
||||
let lock_file = match std::fs::File::create(&lock_path) {
|
||||
std::result::Result::Ok(lock) => lock,
|
||||
std::result::Result::Err(error) => {
|
||||
eprintln!("cannot create application lock '{}': {error}", lock_path.display());
|
||||
return std::process::ExitCode::FAILURE;
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = lock_file.try_lock_exclusive() {
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock {
|
||||
eprintln!("another kb_app_demo instance is already running");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
eprintln!("cannot acquire application lock '{}': {error}", lock_path.display());
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
let _lock_file = lock_file;
|
||||
let run_result = kb_app_demo_lib::run().await;
|
||||
return match run_result {
|
||||
std::result::Result::Ok(()) => std::process::ExitCode::SUCCESS,
|
||||
std::result::Result::Err(error) => {
|
||||
eprintln!("application error: {error}");
|
||||
std::process::ExitCode::FAILURE
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// file: kb_app_demo/src/main_window.rs
|
||||
// version: 3
|
||||
|
||||
//! Main window commands and workspace README loading helpers.
|
||||
|
||||
pub(crate) fn workspace_root_dir() -> std::path::PathBuf {
|
||||
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
return match manifest_dir.parent() {
|
||||
std::option::Option::Some(parent) => parent.to_path_buf(),
|
||||
std::option::Option::None => manifest_dir,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn strip_readme_metadata_comments(content: &str) -> std::string::String {
|
||||
let mut output = std::string::String::new();
|
||||
let mut leading_metadata = true;
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if leading_metadata && is_readme_metadata_comment(trimmed) {
|
||||
continue;
|
||||
}
|
||||
leading_metadata = false;
|
||||
output.push_str(line);
|
||||
output.push('\n');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
fn is_readme_metadata_comment(line: &str) -> bool {
|
||||
if line.starts_with("<!-- file:") || line.starts_with("<!--- file:") {
|
||||
return true;
|
||||
}
|
||||
if line.starts_with("<!-- version:") || line.starts_with("<!--- version:") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn strip_readme_metadata_comments_removes_workspace_header_lines() {
|
||||
let content = "<!-- file: README.md -->\n<!-- version: 7 -->\n# Title\nBody\n";
|
||||
let cleaned = crate::strip_readme_metadata_comments(content);
|
||||
assert_eq!(cleaned, "# Title\nBody\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_readme_metadata_comments_keeps_later_html_comments() {
|
||||
let content = "<!-- file: README.md -->\n# Title\n<!-- normal comment -->\nBody\n";
|
||||
let cleaned = crate::strip_readme_metadata_comments(content);
|
||||
assert_eq!(cleaned, "# Title\n<!-- normal comment -->\nBody\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// file: kb_app_demo/src/splash.rs
|
||||
// version: 5
|
||||
|
||||
//! Shared splash-screen payload types and helpers.
|
||||
//!
|
||||
//! The startup sequence is intentionally wired in `lib.rs` so it stays close to
|
||||
//! the Tauri builder setup pattern used by the previous demo application.
|
||||
|
||||
use tauri::Emitter; // rust-rules: trait-import
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
/// Minimum startup splash duration before fade-out starts.
|
||||
pub(crate) const SPLASH_MINIMUM_MS: u64 = 3100;
|
||||
/// Splash fade-in and fade-out animation duration.
|
||||
pub(crate) const SPLASH_FADE_MS: u32 = 3000;
|
||||
/// Delay after the fade-out command before destroying the splash window.
|
||||
pub(crate) const SPLASH_CLOSE_WAIT_MS: u64 = 3100;
|
||||
|
||||
/// Command payload sent from Rust to the splash frontend.
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, TS)]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/kb_app_demo/splash/SplashOrder.ts")]
|
||||
pub(crate) struct SplashOrder {
|
||||
/// Splash command name such as `fadein`, `fadeout`, `add_msg`, or `add_log`.
|
||||
pub(crate) order: std::string::String,
|
||||
/// Optional message payload attached to the command.
|
||||
pub(crate) msg: std::option::Option<std::string::String>,
|
||||
/// Optional status payload attached to the command.
|
||||
pub(crate) status: std::option::Option<std::string::String>,
|
||||
/// Optional animation duration in milliseconds.
|
||||
pub(crate) duration_ms: std::option::Option<u32>,
|
||||
}
|
||||
|
||||
/// Emits a splash-screen order to the splash WebView.
|
||||
pub(crate) fn emit_splash_order(
|
||||
splash_window: &tauri::WebviewWindow,
|
||||
order: &str,
|
||||
msg: std::option::Option<&str>,
|
||||
status: std::option::Option<&str>,
|
||||
duration_ms: std::option::Option<u32>,
|
||||
) {
|
||||
let payload = crate::SplashOrder {
|
||||
order: order.to_string(),
|
||||
msg: msg.map(std::string::ToString::to_string),
|
||||
status: status.map(std::string::ToString::to_string),
|
||||
duration_ms,
|
||||
};
|
||||
let emit_result = splash_window.emit("splash", payload);
|
||||
if let std::result::Result::Err(error) = emit_result {
|
||||
tracing::error!(target: crate::TRACING_TARGET, "error emitting splash event '{order}': {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the minimum splash duration has elapsed.
|
||||
pub(crate) async fn wait_until_minimum(start: tokio::time::Instant, minimum_ms: u64) {
|
||||
let minimum = std::time::Duration::from_millis(minimum_ms);
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed >= minimum {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(minimum - elapsed).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn splash_order_serializes_duration_ms() {
|
||||
let order = crate::SplashOrder {
|
||||
order: "fadein".to_string(),
|
||||
msg: std::option::Option::None,
|
||||
status: std::option::Option::None,
|
||||
duration_ms: std::option::Option::Some(3000),
|
||||
};
|
||||
let value_result = serde_json::to_value(order);
|
||||
let value = match value_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("cannot serialize splash order: {error}"),
|
||||
};
|
||||
assert_eq!(value["duration_ms"], serde_json::json!(3000));
|
||||
}
|
||||
}
|
||||
2258
migration/khadhroony-bot2-reference/kb_app_demo/src/tauri.rs
Normal file
2258
migration/khadhroony-bot2-reference/kb_app_demo/src/tauri.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "kb-app-demo",
|
||||
"version": "0.4.6",
|
||||
"identifier": "com.sasedev.kb-app-demo",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "./dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "splash",
|
||||
"url": "splash.html",
|
||||
"title": "Loading ... Khadhroony Bot2",
|
||||
"width": 960,
|
||||
"height": 637,
|
||||
"resizable": false,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"center": true,
|
||||
"alwaysOnTop": true
|
||||
},
|
||||
{
|
||||
"label": "main",
|
||||
"url": "main.html",
|
||||
"title": "Khadhroony-Bot2",
|
||||
"width": 1024,
|
||||
"height": 768,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"center": true,
|
||||
"visible": false,
|
||||
"transparent": false,
|
||||
"decorations": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/favicon.png",
|
||||
"icons/favicon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": [
|
||||
"vite/client",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"frontend",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// file: kb_app_demo/vite.config.ts
|
||||
// version: 13
|
||||
|
||||
import { defineConfig, normalizePath } from "vite";
|
||||
import { NodePackageImporter } from "sass-embedded";
|
||||
import { resolve } from 'path';
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(() => ({
|
||||
|
||||
envPrefix: ['VITE_', 'TAURI_ENV_*'],
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
root: 'frontend', // Set this to your frontend directory
|
||||
publicDir: 'public',
|
||||
build: {
|
||||
outDir: './dist', // Output directory for the build
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
"splash": normalizePath(resolve(__dirname, 'frontend/splash.html')),
|
||||
"main": normalizePath(resolve(__dirname, 'frontend/main.html')),
|
||||
"demo_config": normalizePath(resolve(__dirname, 'frontend/demo_config.html')),
|
||||
"demo_http": normalizePath(resolve(__dirname, 'frontend/demo_http.html')),
|
||||
"demo_ws": normalizePath(resolve(__dirname, 'frontend/demo_ws.html')),
|
||||
"demo_backfill": normalizePath(resolve(__dirname, 'frontend/demo_backfill.html')),
|
||||
"demo_core_extraction": normalizePath(resolve(__dirname, 'frontend/demo_core_extraction.html')),
|
||||
"demo_decode_replay": normalizePath(resolve(__dirname, 'frontend/demo_decode_replay.html')),
|
||||
"demo_execution_solana_core": normalizePath(resolve(__dirname, 'frontend/demo_execution_solana_core.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')),
|
||||
"demo_execution_spl": normalizePath(resolve(__dirname, 'frontend/demo_execution_spl.html'))
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'js/[name]-[hash].js',
|
||||
chunkFileNames: 'js/chunks/[name]-[hash].js',
|
||||
assetFileNames: (assetInfo) => {
|
||||
const originalName = assetInfo.name ?? '';
|
||||
const ext = originalName.substring(originalName.lastIndexOf('.') + 1).toLowerCase();
|
||||
if (ext === 'js') {
|
||||
return 'js/[name]-[hash][extname]';
|
||||
}
|
||||
// CSS
|
||||
if (ext === 'css') {
|
||||
return 'css/[name]-[hash][extname]';
|
||||
}
|
||||
if (['eot', 'otf', 'ttf', 'woff', 'woff2'].includes(ext)) {
|
||||
return 'fonts/[name]-[hash][extname]';
|
||||
}
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'].includes(ext)) {
|
||||
return 'imgs/[name][extname]';
|
||||
}
|
||||
if (['mp4', 'webm'].includes(ext)) {
|
||||
return 'videos/[name][extname]';
|
||||
}
|
||||
return 'otherassets/[name][extname]';
|
||||
},
|
||||
},
|
||||
},
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
cssCodeSplit: true
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
quietDeps: true,
|
||||
silenceDeprecations: ["import", "color-functions", "global-builtin",],
|
||||
verbose: false,
|
||||
api: 'modern',
|
||||
importers: [new NodePackageImporter()],
|
||||
}
|
||||
}
|
||||
},
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: false,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user