This commit is contained in:
2026-04-22 19:04:22 +02:00
parent 23dab2df85
commit f073b14e01
18 changed files with 1072 additions and 180 deletions

View File

@@ -15,3 +15,4 @@
0.4.1 - Ajout des premiers helpers HTTP Solana haut niveau, dans la continuité de lAPI du client WebSocket
0.4.2 - Préparation de la politique HTTP avancée : états de pause avant envoi, quotas par famille de méthodes et futur pool dendpoints
0.4.3 - Pool dendpoints HTTP
0.4.4 - Ajout de la fenêtre Demo Http dans kb_app, exécution manuelle des méthodes HTTP via le pool, snapshot des endpoints et amélioration des presets UI

View File

@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
version = "0.4.3"
version = "0.4.4"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-bobobot"

View File

@@ -254,7 +254,7 @@ Réalisé :
Objectif : rendre la fenêtre de démonstration robuste sous flux élevé et cohérente avec la configuration.
À faire :
Réalisé :
- lire correctement les endpoints activés depuis la config et refléter les URLs résolues avec `api_key_env_var`,
- améliorer la sélection réelle des endpoints affichés et utilisables,
@@ -270,7 +270,8 @@ Objectif : rendre la fenêtre de démonstration robuste sous flux élevé et coh
Objectif : construire un `HttpClient` clonable, limité et extensible, puis ajouter les premiers helpers HTTP Solana.
### 0.4.0 — Socle `HttpClient`
À faire :
Réalisé :
- client `reqwest` asynchrone clonable,
- résolution dURL avec support de `api_key_env_var`,
@@ -291,7 +292,8 @@ Livrables :
- `getSlot`
### 0.4.1 — Helpers HTTP Solana
À faire :
Réalisé :
- ajouter des helpers HTTP haut niveau comme pour le client WS,
- distinguer helpers raw et helpers typed quand cela est pertinent,
@@ -299,7 +301,8 @@ Livrables :
- conserver `HttpClient` comme couche générique réutilisable.
### 0.4.2 — Politique HTTP avancée
À faire :
Réalisé :
- préparer un état de pause avant envoi pour un endpoint HTTP,
- préparer plusieurs quotas par famille de méthodes,
@@ -307,7 +310,8 @@ Livrables :
- préparer un futur pool dendpoints HTTP et larbitrage entre eux.
### 0.4.3 — Pool dendpoints HTTP
À faire :
Réalisé :
- ajouter un pool d`HttpClient`,
- sélectionner un endpoint selon le rôle demandé,
@@ -317,13 +321,16 @@ Livrables :
- préparer le routage multi-RPC et la limitation de concurrence par endpoint.
### 0.4.4 — Démo HTTP dans `kb_app`
À faire :
- ajouter une fenêtre `Demo Http`,
- suivre la logique de `Demo Ws`,
- permettre de tester les endpoints HTTP configurés,
- afficher les réponses JSON-RPC HTTP et les erreurs associées,
- exposer létat du pool HTTP et les statuts des endpoints sélectionnables.
Réalisé :
- ajout dune fenêtre `Demo Http`,
- ouverture depuis la fenêtre principale,
- exécution manuelle de méthodes HTTP via le pool dendpoints,
- affichage des réponses JSON-RPC HTTP et des erreurs associées,
- affichage de létat du pool HTTP et des statuts des endpoints,
- alignement visuel de la fenêtre sur le gabarit `Demo Ws`,
- amélioration des presets UI, copie de réponse et bascule pretty/raw.
### 6.12. Version `0.5.x` — Base de données SQLite
@@ -496,9 +503,9 @@ Le projet doit maintenir au minimum :
La priorité immédiate est désormais la suivante :
1. finaliser la version `0.4.3` avec le pool dendpoints HTTP,
2. exploiter les statuts `Active` / `Paused` / `Disabled` dans la sélection dendpoint,
3. préparer le routage multi-RPC selon le rôle demandé et la classe de méthode,
4. conserver `HttpClient` comme brique générique utilisable sous le pool,
5. démarrer ensuite la version `0.4.4` avec une fenêtre `Demo Http` dans `kb_app`,
6. exposer dans `kb_app` les réponses HTTP, les erreurs et létat du pool.
1. démarrer la version `0.5.x` avec le socle SQLite,
2. ajouter la configuration database dans `config.json`,
3. poser louverture et la validation de la base SQLite,
4. définir les premières tables techniques utiles au stockage local,
5. préparer la persistance des endpoints, événements et tokens observés,
6. conserver `kb_lib` comme point central de la logique de stockage.

View File

@@ -5,7 +5,8 @@
"windows": [
"main",
"splash",
"demo_ws"
"demo_ws",
"demo_http"
],
"permissions": [
"core:default",

View File

@@ -0,0 +1,166 @@
<!-- file: kb_app/frontend/demo_http.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo 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="/">
<img alt="Logo" src="imgs/logo.png" class="app-logo" />
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Khadhroony-BoBoBot</span>
</a>
<div class="ms-auto">
<span id="demoHttpStatusBadge" class="badge text-bg-secondary">Ready</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="h4 mb-3">Demo Http</h1>
<p class="text-body-secondary mb-0">
Fenêtre de test manuelle pour les requêtes HTTP Solana via le pool dendpoints configurés.
</p>
</div>
</div>
</div>
<div class="col-12 col-xxl-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<h2 class="h5 mb-3">Requête</h2>
<div class="mb-3">
<label for="demoHttpRoleSelect" class="form-label">Role</label>
<select id="demoHttpRoleSelect" class="form-select">
<option value="http_queries">http_queries</option>
<option value="http_transactions">http_transactions</option>
<option value="http_heavy">http_heavy</option>
<option value="http_fallback">http_fallback</option>
</select>
<div class="form-text">
Le rôle détermine quel endpoint HTTP du pool sera sélectionné.
</div>
</div>
<div class="mb-3">
<label for="demoHttpMethodSelect" class="form-label">Méthode</label>
<select id="demoHttpMethodSelect" class="form-select">
<option value="getHealth">getHealth</option>
<option value="getVersion">getVersion</option>
<option value="getSlot">getSlot</option>
<option value="getBlockHeight">getBlockHeight</option>
<option value="getLatestBlockhash">getLatestBlockhash</option>
<option value="getBalance">getBalance</option>
<option value="getAccountInfo">getAccountInfo</option>
<option value="getProgramAccounts">getProgramAccounts</option>
<option value="getSignaturesForAddress">getSignaturesForAddress</option>
<option value="getTransaction">getTransaction</option>
<option value="sendTransaction">sendTransaction</option>
</select>
</div>
<div id="demoHttpFirstArgGroup" class="mb-3">
<label for="demoHttpFirstArgInput" id="demoHttpFirstArgLabel" class="form-label">First arg</label>
<input id="demoHttpFirstArgInput" type="text" class="form-control" spellcheck="false" />
<div id="demoHttpFirstArgHelp" class="form-text">
Adresse, program id, signature ou transaction encodée selon la méthode.
</div>
</div>
<div id="demoHttpConfigGroup" class="mb-3">
<label for="demoHttpConfigTextarea" class="form-label">Config JSON</label>
<textarea id="demoHttpConfigTextarea" class="form-control font-monospace" rows="8" spellcheck="false"></textarea>
<div id="demoHttpConfigHelp" class="form-text">
Configuration JSON optionnelle.
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button id="demoHttpExecuteButton" type="button" class="btn btn-primary">Execute</button>
<button id="demoHttpRefreshPoolButton" type="button" class="btn btn-outline-primary">Refresh pool</button>
<button id="demoHttpCopyResponseButton" type="button" class="btn btn-outline-secondary">Copy response</button>
<button id="demoHttpClearLogButton" type="button" class="btn btn-outline-secondary">Clear log</button>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="demoHttpPrettyToggle" checked />
<label class="form-check-label" for="demoHttpPrettyToggle">Pretty JSON</label>
</div>
<div class="small text-body-secondary">
<div><strong>Endpoint:</strong> <span id="demoHttpLastEndpointText">-</span></div>
<div><strong>Provider:</strong> <span id="demoHttpLastProviderText">-</span></div>
<div><strong>Method class:</strong> <span id="demoHttpLastMethodClassText">-</span></div>
</div>
</div>
</div>
</div>
<div class="col-12 col-xxl-8">
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<h2 class="h5 mb-3">Pool HTTP</h2>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Endpoint</th>
<th>Status</th>
<th>Paused</th>
<th>Conc.</th>
</tr>
</thead>
<tbody id="demoHttpPoolTableBody"></tbody>
</table>
</div>
</div>
</div>
<div class="card shadow-sm border-0 mb-4">
<div class="card-body">
<h2 class="h5 mb-3">Latest response</h2>
<textarea id="demoHttpResponseTextarea" class="form-control font-monospace" rows="12" readonly spellcheck="false"></textarea>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-body">
<h2 class="h5 mb-3">Logs</h2>
<textarea id="demoHttpLogTextarea" class="form-control font-monospace" rows="14" readonly spellcheck="false"></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 col-md-6 text-center text-small my-1 my-md-0">
&copy; 2026 SASEDEV — Demo Http
</div>
</div>
</div>
</footer>
<script type="module" src="ts/demo_http.ts" defer></script>
</body>
</html>

View File

@@ -1,12 +1,14 @@
<!-- file: kb_app/frontend/demo_ws.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo Ws Subscribe</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">
@@ -50,7 +52,7 @@
<code>enabled: true</code> apparaissent ici. Les endpoints HTTP ne sont pas utilisés
par cette fenêtre.
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button id="demoWsConnectButton" type="button" class="btn btn-success">Connect</button>
@@ -68,7 +70,7 @@
<div><strong>UI logs:</strong> <span id="demoWsUiLogCountText">0</span></div>
<div><strong>Suppressed:</strong> <span id="demoWsSuppressedLogCountText">0</span></div>
<div><strong>Last event:</strong> <span id="demoWsLastEventKindText">-</span></div>
</div>
</div>
<hr />
@@ -104,22 +106,12 @@
<div id="demoWsFilterGroup" class="mb-3">
<label for="demoWsFilterTextarea" class="form-label">Filter JSON</label>
<textarea
id="demoWsFilterTextarea"
class="form-control font-monospace"
rows="5"
spellcheck="false"
></textarea>
<textarea id="demoWsFilterTextarea" class="form-control font-monospace" rows="5" spellcheck="false"></textarea>
</div>
<div id="demoWsConfigGroup" class="mb-3">
<label for="demoWsConfigTextarea" class="form-label">Config JSON</label>
<textarea
id="demoWsConfigTextarea"
class="form-control font-monospace"
rows="6"
spellcheck="false"
></textarea>
<textarea id="demoWsConfigTextarea" class="form-control font-monospace" rows="6" spellcheck="false"></textarea>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
@@ -140,13 +132,7 @@
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<h2 class="h5 mb-3">Logs</h2>
<textarea
id="demoWsLogTextarea"
class="form-control font-monospace"
rows="28"
readonly
spellcheck="false"
></textarea>
<textarea id="demoWsLogTextarea" class="form-control font-monospace" rows="28" readonly spellcheck="false"></textarea>
</div>
</div>
</div>
@@ -167,4 +153,5 @@
<script type="module" src="ts/demo_ws.ts" defer></script>
</body>
</html>

View File

@@ -1,12 +1,14 @@
<!-- file: kb_app/frontend/index.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Khadhroony-BoBoBot</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">
@@ -20,6 +22,9 @@
<button id="openDemoWsButton" type="button" class="btn btn-outline-primary">
Demo Ws
</button>
<button id="openDemoHttpButton" class="btn btn-outline-primary">
Demo Http
</button>
</div>
</div>
</nav>
@@ -40,14 +45,23 @@
<h3 class="h5 card-title mb-3">Desktop shell</h3>
<p class="text-body-secondary mb-3">
La fenêtre principale reste volontairement légère.
</p>
<p class="text-body-secondary mb-3">
Les tests WebSocket manuels sont disponibles dans la fenêtre dédiée
<strong>Demo Ws</strong>.
</p>
<p class="text-body-secondary mb-3">
Les tests PRC Http manuels sont disponibles dans la fenêtre dédiée
<strong>Demo Http</strong>.
</p>
<div class="d-flex flex-wrap gap-2">
<button id="openDemoWsButtonSecondary" type="button" class="btn btn-primary">
Ouvrir Demo Ws
</button>
<button id="openDemoHttpButtonSecondary" type="button" class="btn btn-primary">
Ouvrir Demo Http
</button>
</div>
<hr />
@@ -77,4 +91,5 @@
<script type="module" src="ts/main.ts" defer></script>
</body>
</html>

View File

@@ -1,12 +1,14 @@
<!-- file: kb_app/frontend/splash.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading ... Khadhroony Solana BoBot</title>
<link rel="stylesheet" href="sass/splash.scss" />
</head>
<body>
<div id="splash-container">
<img id="splash-image" src="imgs/splash.png" alt="Application Loading" />
@@ -16,4 +18,5 @@
</div>
<script type="module" src="ts/splash.ts" defer></script>
</body>
</html>

View File

@@ -0,0 +1,437 @@
import * as bootstrap from "bootstrap";
import "simplebar";
import ResizeObserver from "resize-observer-polyfill";
import { invoke } from "@tauri-apps/api/core";
import { debug, takeoverConsole } from "@fltsci/tauri-plugin-tracing";
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
interface DemoHttpPoolClientSnapshot {
endpointName: string;
provider: string;
endpointUrl: string;
roles: string[];
status: string;
pausedRemainingMs: number | null;
availableConcurrencySlots: number;
}
interface DemoHttpRequest {
role: string;
method: string;
firstArg: string | null;
configJson: string | null;
}
interface DemoHttpExecutionPayload {
endpointName: string;
provider: string;
endpointUrl: string;
role: string;
method: string;
methodClass: string;
responseJson: string;
}
let demoHttpLastResponseRawText = "";
function appendLogLine(textarea: HTMLTextAreaElement, line: string): void {
const now = new Date();
const timestamp = now.toLocaleTimeString("fr-CH", { hour12: false });
const lines = textarea.value === "" ? [] : textarea.value.split("\n");
lines.push(`[${timestamp}] ${line}`);
const maxLines = 400;
textarea.value = lines.slice(-maxLines).join("\n");
textarea.scrollTop = textarea.scrollHeight;
}
function methodNeedsFirstArg(method: string): boolean {
return [
"getBalance",
"getAccountInfo",
"getProgramAccounts",
"getSignaturesForAddress",
"getTransaction",
"sendTransaction",
].includes(method);
}
function firstArgLabelForMethod(method: string): string {
if (method === "getBalance") return "Address";
if (method === "getAccountInfo") return "Address";
if (method === "getProgramAccounts") return "Program id";
if (method === "getSignaturesForAddress") return "Address";
if (method === "getTransaction") return "Signature";
if (method === "sendTransaction") return "Encoded transaction (base64)";
return "First arg";
}
function firstArgHelpForMethod(method: string): string {
if (method === "getBalance") return "Adresse Solana dont on veut lire le solde.";
if (method === "getAccountInfo") return "Adresse Solana du compte à lire.";
if (method === "getProgramAccounts") return "Program id dont on veut lister les comptes.";
if (method === "getSignaturesForAddress") return "Adresse Solana dont on veut lire les signatures récentes.";
if (method === "getTransaction") return "Signature exacte dune transaction.";
if (method === "sendTransaction") {
return "Transaction signée encodée en base64. Le preset fourni est volontairement invalide et sert seulement à tester la gestion derreur.";
}
return "Adresse, program id, signature ou transaction encodée selon la méthode.";
}
function configHelpForMethod(method: string): string {
if (method === "getProgramAccounts") {
return "Preset volontairement filtré pour éviter une réponse trop large.";
}
if (method === "sendTransaction") {
return "Configuration denvoi. Avec le preset de transaction invalide, la requête doit échouer côté RPC.";
}
return "Configuration JSON optionnelle.";
}
function defaultRoleForMethod(method: string): string {
if (method === "sendTransaction") return "http_transactions";
if (method === "getProgramAccounts") return "http_heavy";
return "http_queries";
}
function defaultFirstArgForMethod(method: string): string {
if (method === "getBalance") return "11111111111111111111111111111111";
if (method === "getAccountInfo") return "11111111111111111111111111111111";
if (method === "getProgramAccounts") return "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
if (method === "getSignaturesForAddress") return "11111111111111111111111111111111";
if (method === "getTransaction") {
return "5h6xBEauJ3PK6SWC7r7J2W8mE1D7aQj4J6Jg8n1SmWnVqSg9H6gq2K7xwJkL2GZ2RZ6n9wYk9cW1b2V3a4d5e6f7";
}
if (method === "sendTransaction") return "AQ==";
return "";
}
function defaultConfigForMethod(method: string): string {
if (method === "getSlot" || method === "getBlockHeight" || method === "getLatestBlockhash") {
return `{"commitment":"confirmed"}`;
}
if (method === "getBalance") {
return `{"commitment":"confirmed"}`;
}
if (method === "getAccountInfo") {
return `{"encoding":"base64","commitment":"confirmed"}`;
}
if (method === "getProgramAccounts") {
return `{
"encoding":"base64",
"commitment":"confirmed",
"dataSlice":{"offset":0,"length":0},
"filters":[
{"dataSize":165},
{"memcmp":{"offset":32,"bytes":"11111111111111111111111111111111"}}
]
}`;
}
if (method === "getSignaturesForAddress") {
return `{"limit":10,"commitment":"confirmed"}`;
}
if (method === "getTransaction") {
return `{"encoding":"json","commitment":"confirmed","maxSupportedTransactionVersion":0}`;
}
if (method === "sendTransaction") {
return `{"encoding":"base64","skipPreflight":true,"maxRetries":0}`;
}
return "";
}
function renderPoolSnapshots(
snapshots: DemoHttpPoolClientSnapshot[],
tableBody: HTMLTableSectionElement,
): void {
tableBody.innerHTML = "";
for (const snapshot of snapshots) {
const row = document.createElement("tr");
const endpointCell = document.createElement("td");
endpointCell.innerHTML = `<div class="fw-semibold">${snapshot.endpointName}</div><div class="small text-body-secondary">${snapshot.endpointUrl}</div>`;
const providerCell = document.createElement("td");
providerCell.textContent = snapshot.provider;
const statusCell = document.createElement("td");
statusCell.textContent = snapshot.status;
const pausedCell = document.createElement("td");
pausedCell.textContent = snapshot.pausedRemainingMs === null ? "-" : String(snapshot.pausedRemainingMs);
const concurrencyCell = document.createElement("td");
concurrencyCell.textContent = String(snapshot.availableConcurrencySlots);
const rolesCell = document.createElement("td");
rolesCell.textContent = snapshot.roles.join(", ");
row.appendChild(endpointCell);
row.appendChild(providerCell);
row.appendChild(statusCell);
row.appendChild(pausedCell);
row.appendChild(concurrencyCell);
row.appendChild(rolesCell);
tableBody.appendChild(row);
}
}
function renderResponse(
responseTextarea: HTMLTextAreaElement,
prettyToggle: HTMLInputElement,
): void {
if (demoHttpLastResponseRawText.trim() === "") {
responseTextarea.value = "";
return;
}
if (!prettyToggle.checked) {
responseTextarea.value = demoHttpLastResponseRawText;
return;
}
try {
const parsed = JSON.parse(demoHttpLastResponseRawText);
responseTextarea.value = JSON.stringify(parsed, null, 2);
} catch {
responseTextarea.value = demoHttpLastResponseRawText;
}
}
async function copyTextToClipboard(text: string): Promise<void> {
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const tempTextarea = document.createElement("textarea");
tempTextarea.value = text;
tempTextarea.style.position = "fixed";
tempTextarea.style.left = "-9999px";
document.body.appendChild(tempTextarea);
tempTextarea.focus();
tempTextarea.select();
document.execCommand("copy");
document.body.removeChild(tempTextarea);
}
function updateMethodForm(
roleSelect: HTMLSelectElement,
methodSelect: HTMLSelectElement,
firstArgGroup: HTMLDivElement,
firstArgLabel: HTMLLabelElement,
firstArgHelp: HTMLDivElement,
firstArgInput: HTMLInputElement,
configTextarea: HTMLTextAreaElement,
configHelp: HTMLDivElement,
): void {
const method = methodSelect.value;
firstArgGroup.style.display = methodNeedsFirstArg(method) ? "" : "none";
firstArgLabel.textContent = firstArgLabelForMethod(method);
firstArgHelp.textContent = firstArgHelpForMethod(method);
firstArgInput.value = defaultFirstArgForMethod(method);
configTextarea.value = defaultConfigForMethod(method);
configHelp.textContent = configHelpForMethod(method);
roleSelect.value = defaultRoleForMethod(method);
}
async function refreshPoolSnapshot(
tableBody: HTMLTableSectionElement,
logTextarea: HTMLTextAreaElement,
shouldLog: boolean,
): Promise<void> {
try {
const snapshots = await invoke<DemoHttpPoolClientSnapshot[]>("demo_http_list_pool_clients");
renderPoolSnapshots(snapshots, tableBody);
if (shouldLog) {
appendLogLine(logTextarea, `[ui] refreshed http pool snapshot (${snapshots.length} endpoint(s))`);
}
} catch (error) {
appendLogLine(logTextarea, `[ui] refresh pool error: ${String(error)}`);
}
}
document.addEventListener("DOMContentLoaded", async () => {
void takeoverConsole();
const sidebarToggle = document.querySelector<HTMLButtonElement>('#sidebarToggle');
if (sidebarToggle) {
// restaurer létat depuis localStorage
if (localStorage.getItem('sidebar-toggle') === 'true') {
document.body.classList.add('sidenav-toggled');
}
sidebarToggle.addEventListener('click', (event) => {
event.preventDefault();
document.body.classList.toggle('sidenav-toggled');
localStorage.setItem('sidebar-toggle', document.body.classList.contains('sidenav-toggled') ? 'true' : 'false');
});
}
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 gobackto = location.pathname + location.search;
document.querySelectorAll<HTMLAnchorElement>('a[data-setlang]').forEach((a) => {
const href = a.getAttribute("href");
if (!href) return; // pas de href => on ignore
const url = new URL(href, location.origin);
url.searchParams.set("gobackto", gobackto);
// conserve une URL relative (path + query)
a.setAttribute("href", url.pathname + "?" + url.searchParams.toString());
});
const roleSelect = document.querySelector<HTMLSelectElement>("#demoHttpRoleSelect");
const methodSelect = document.querySelector<HTMLSelectElement>("#demoHttpMethodSelect");
const firstArgGroup = document.querySelector<HTMLDivElement>("#demoHttpFirstArgGroup");
const firstArgLabel = document.querySelector<HTMLLabelElement>("#demoHttpFirstArgLabel");
const firstArgHelp = document.querySelector<HTMLDivElement>("#demoHttpFirstArgHelp");
const firstArgInput = document.querySelector<HTMLInputElement>("#demoHttpFirstArgInput");
const configTextarea = document.querySelector<HTMLTextAreaElement>("#demoHttpConfigTextarea");
const configHelp = document.querySelector<HTMLDivElement>("#demoHttpConfigHelp");
const executeButton = document.querySelector<HTMLButtonElement>("#demoHttpExecuteButton");
const refreshPoolButton = document.querySelector<HTMLButtonElement>("#demoHttpRefreshPoolButton");
const clearLogButton = document.querySelector<HTMLButtonElement>("#demoHttpClearLogButton");
const copyResponseButton = document.querySelector<HTMLButtonElement>("#demoHttpCopyResponseButton");
const prettyToggle = document.querySelector<HTMLInputElement>("#demoHttpPrettyToggle");
const responseTextarea = document.querySelector<HTMLTextAreaElement>("#demoHttpResponseTextarea");
const logTextarea = document.querySelector<HTMLTextAreaElement>("#demoHttpLogTextarea");
const poolTableBody = document.querySelector<HTMLTableSectionElement>("#demoHttpPoolTableBody");
const lastEndpointText = document.querySelector<HTMLSpanElement>("#demoHttpLastEndpointText");
const lastProviderText = document.querySelector<HTMLSpanElement>("#demoHttpLastProviderText");
const lastMethodClassText = document.querySelector<HTMLSpanElement>("#demoHttpLastMethodClassText");
if (
!roleSelect ||
!methodSelect ||
!firstArgGroup ||
!firstArgLabel ||
!firstArgHelp ||
!firstArgInput ||
!configTextarea ||
!configHelp ||
!executeButton ||
!refreshPoolButton ||
!clearLogButton ||
!copyResponseButton ||
!prettyToggle ||
!responseTextarea ||
!logTextarea ||
!poolTableBody ||
!lastEndpointText ||
!lastProviderText ||
!lastMethodClassText
) {
debug("demo_http UI controls not found");
return;
}
updateMethodForm(
roleSelect,
methodSelect,
firstArgGroup,
firstArgLabel,
firstArgHelp,
firstArgInput,
configTextarea,
configHelp,
);
methodSelect.addEventListener("change", () => {
updateMethodForm(
roleSelect,
methodSelect,
firstArgGroup,
firstArgLabel,
firstArgHelp,
firstArgInput,
configTextarea,
configHelp,
);
});
prettyToggle.addEventListener("change", () => {
renderResponse(responseTextarea, prettyToggle);
});
refreshPoolButton.addEventListener("click", async () => {
await refreshPoolSnapshot(poolTableBody, logTextarea, true);
});
clearLogButton.addEventListener("click", () => {
logTextarea.value = "";
});
copyResponseButton.addEventListener("click", async () => {
if (responseTextarea.value.trim() === "") {
appendLogLine(logTextarea, "[ui] copy skipped: no response available");
return;
}
try {
await copyTextToClipboard(responseTextarea.value);
appendLogLine(logTextarea, "[ui] response copied to clipboard");
} catch (error) {
appendLogLine(logTextarea, `[ui] copy response error: ${String(error)}`);
}
});
executeButton.addEventListener("click", async () => {
const request: DemoHttpRequest = {
role: roleSelect.value,
method: methodSelect.value,
firstArg: firstArgInput.value.trim() === "" ? null : firstArgInput.value.trim(),
configJson: configTextarea.value.trim() === "" ? null : configTextarea.value.trim(),
};
try {
const response = await invoke<DemoHttpExecutionPayload>("demo_http_execute_request", { request });
lastEndpointText.textContent = `${response.endpointName} (${response.endpointUrl})`;
lastProviderText.textContent = response.provider;
lastMethodClassText.textContent = response.methodClass;
demoHttpLastResponseRawText = response.responseJson;
renderResponse(responseTextarea, prettyToggle);
appendLogLine(
logTextarea,
`[ui] ${response.method} via ${response.endpointName} / role=${response.role} / class=${response.methodClass}`,
);
await refreshPoolSnapshot(poolTableBody, logTextarea, true);
} catch (error) {
demoHttpLastResponseRawText = String(error);
renderResponse(responseTextarea, prettyToggle);
appendLogLine(logTextarea, `[ui] execute error: ${String(error)}`);
await refreshPoolSnapshot(poolTableBody, logTextarea, true);
}
});
appendLogLine(logTextarea, "[ui] demo_http window loaded");
await refreshPoolSnapshot(poolTableBody, logTextarea, true);
debug("demo_http window loaded");
});

View File

@@ -5,7 +5,7 @@ import "simplebar";
import ResizeObserver from "resize-observer-polyfill";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { trace, takeoverConsole } from "@fltsci/tauri-plugin-tracing";
import { debug, takeoverConsole } from "@fltsci/tauri-plugin-tracing";
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
@@ -290,10 +290,10 @@ document.addEventListener("DOMContentLoaded", async () => {
const subscriptionText = document.querySelector<HTMLSpanElement>("#demoWsSubscriptionText");
const requestText = document.querySelector<HTMLSpanElement>("#demoWsRequestText");
const eventCountText = document.querySelector<HTMLSpanElement>("#demoWsEventCountText");
const notificationCountText = document.querySelector<HTMLSpanElement>("#demoWsNotificationCountText");
const uiLogCountText = document.querySelector<HTMLSpanElement>("#demoWsUiLogCountText");
const suppressedLogCountText = document.querySelector<HTMLSpanElement>("#demoWsSuppressedLogCountText");
const lastEventKindText = document.querySelector<HTMLSpanElement>("#demoWsLastEventKindText");
const notificationCountText = document.querySelector<HTMLSpanElement>("#demoWsNotificationCountText");
const uiLogCountText = document.querySelector<HTMLSpanElement>("#demoWsUiLogCountText");
const suppressedLogCountText = document.querySelector<HTMLSpanElement>("#demoWsSuppressedLogCountText");
const lastEventKindText = document.querySelector<HTMLSpanElement>("#demoWsLastEventKindText");
const connectButton = document.querySelector<HTMLButtonElement>("#demoWsConnectButton");
const disconnectButton = document.querySelector<HTMLButtonElement>("#demoWsDisconnectButton");
const subscribeButton = document.querySelector<HTMLButtonElement>("#demoWsSubscribeButton");
@@ -308,7 +308,7 @@ const lastEventKindText = document.querySelector<HTMLSpanElement>("#demoWsLastEv
!stateText || !endpointText || !subscriptionText || !requestText || !connectButton ||
!disconnectButton || !subscribeButton || !unsubscribeButton || !clearLogButton || !logTextarea
) {
trace("demo_ws UI controls not found");
debug("demo_ws UI controls not found");
return;
}
@@ -337,7 +337,7 @@ const lastEventKindText = document.querySelector<HTMLSpanElement>("#demoWsLastEv
subscribeButton,
unsubscribeButton,
);
});
});
} catch (error) {
appendLogLine(logTextarea, `[ui] event listen error: ${String(error)}`);
}
@@ -414,7 +414,7 @@ const lastEventKindText = document.querySelector<HTMLSpanElement>("#demoWsLastEv
disconnectButton,
subscribeButton,
unsubscribeButton,
);
);
} catch (error) {
appendLogLine(logTextarea, `[ui] initial status error: ${String(error)}`);
}
@@ -442,7 +442,7 @@ const lastEventKindText = document.querySelector<HTMLSpanElement>("#demoWsLastEv
disconnectButton,
subscribeButton,
unsubscribeButton,
);
);
} catch (error) {
appendLogLine(logTextarea, `[ui] connect error: ${String(error)}`);
}
@@ -467,7 +467,7 @@ const lastEventKindText = document.querySelector<HTMLSpanElement>("#demoWsLastEv
disconnectButton,
subscribeButton,
unsubscribeButton,
);
);
} catch (error) {
appendLogLine(logTextarea, `[ui] disconnect error: ${String(error)}`);
}
@@ -515,5 +515,5 @@ const lastEventKindText = document.querySelector<HTMLSpanElement>("#demoWsLastEv
}
});
trace("demo_ws window loaded");
debug("demo_ws window loaded");
});

View File

@@ -4,7 +4,7 @@ import * as bootstrap from "bootstrap";
import "simplebar";
import ResizeObserver from "resize-observer-polyfill";
import { invoke } from "@tauri-apps/api/core";
import { trace, takeoverConsole } from "@fltsci/tauri-plugin-tracing";
import { debug, takeoverConsole } from "@fltsci/tauri-plugin-tracing";
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
@@ -16,6 +16,13 @@ async function openDemoWsWindow(): Promise<void> {
console.error("open_demo_ws_window failed:", error);
}
}
async function openDemoHttpWindow(): Promise<void> {
try {
await invoke("open_demo_http_window");
} catch (error) {
console.error("open_demo_http_window failed:", error);
}
}
document.addEventListener("DOMContentLoaded", async () => {
void takeoverConsole();
const sidebarToggle = document.querySelector<HTMLButtonElement>('#sidebarToggle');
@@ -55,6 +62,9 @@ document.addEventListener("DOMContentLoaded", async () => {
const openDemoWsButton = document.querySelector<HTMLButtonElement>("#openDemoWsButton");
const openDemoWsButtonSecondary = document.querySelector<HTMLButtonElement>("#openDemoWsButtonSecondary");
const openDemoHttpButton = document.querySelector<HTMLButtonElement>("#openDemoHttpButton");
const openDemoHttpButtonSecondary = document.querySelector<HTMLButtonElement>("#openDemoHttpButtonSecondary");
if (openDemoWsButton) {
openDemoWsButton.addEventListener("click", () => {
void openDemoWsWindow();
@@ -67,6 +77,18 @@ document.addEventListener("DOMContentLoaded", async () => {
});
}
trace("window loaded");
if (openDemoHttpButton) {
openDemoHttpButton.addEventListener("click", () => {
void openDemoHttpWindow();
});
}
if (openDemoHttpButtonSecondary) {
openDemoHttpButtonSecondary.addEventListener("click", () => {
void openDemoHttpWindow();
});
}
debug("window loaded");
});

View File

@@ -80,7 +80,7 @@ listen("splash", (event) => {
} else if (splashorder.order == "add_log" && splashorder.msg) {
addLogMessage(splashorder.msg);
} else {
error("unknown order:"+splashorder.order);
error("unknown order:" + splashorder.order);
}
});

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","splash","demo_ws"],"permissions":["core:default","tracing:default"]}}
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","splash","demo_ws","demo_http"],"permissions":["core:default","tracing:default"]}}

View File

@@ -1,7 +1,7 @@
{
"name": "kb-app",
"private": true,
"version": "0.3.5",
"version": "0.4.4",
"type": "module",
"scripts": {
"dev": "vite",

226
kb_app/src/demo_http.rs Normal file
View File

@@ -0,0 +1,226 @@
// file: kb_app/src/demo_http.rs
//! Tauri commands for the HTTP demo window.
//!
//! This module exposes a small manual test surface over the HTTP endpoint pool.
use tauri::Manager;
/// Request payload for one demo HTTP execution.
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct KbDemoHttpRequest {
/// Logical role used to select one endpoint from the pool.
pub role: std::string::String,
/// JSON-RPC HTTP method name.
pub method: std::string::String,
/// Optional first string argument, used by methods such as
/// `getBalance`, `getAccountInfo`, `getProgramAccounts`,
/// `getSignaturesForAddress`, `getTransaction`, `sendTransaction`.
pub first_arg: std::option::Option<std::string::String>,
/// Optional JSON config payload encoded as a string.
pub config_json: std::option::Option<std::string::String>,
}
/// Response payload for one demo HTTP execution.
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct KbDemoHttpExecutionPayload {
/// Selected endpoint name.
pub endpoint_name: std::string::String,
/// Selected endpoint provider.
pub provider: std::string::String,
/// Selected endpoint URL.
pub endpoint_url: std::string::String,
/// Requested role.
pub role: std::string::String,
/// Executed method name.
pub method: std::string::String,
/// Classified method family.
pub method_class: std::string::String,
/// Pretty-printed JSON response payload.
pub response_json: std::string::String,
}
/// Opens the dedicated HTTP demo window.
#[tauri::command]
pub(crate) fn open_demo_http_window(
app_handle: tauri::AppHandle,
) -> Result<(), std::string::String> {
let existing_window_option = app_handle.get_webview_window("demo_http");
let demo_window = match existing_window_option {
Some(demo_window) => demo_window,
None => {
let builder = tauri::WebviewWindowBuilder::new(
&app_handle,
"demo_http",
tauri::WebviewUrl::App("demo_http.html".into()),
)
.title("Demo Http")
.inner_size(1400.0, 768.0)
.min_inner_size(800.0, 600.0)
.center()
.visible(true)
.transparent(false)
.decorations(true);
let build_result = builder.build();
match build_result {
Ok(window) => window,
Err(error) => {
return Err(format!("cannot create demo_http window: {error:?}"));
}
}
}
};
let show_result = demo_window.show();
if let Err(error) = show_result {
return Err(format!("cannot show demo_http window: {error:?}"));
}
let focus_result = demo_window.set_focus();
if let Err(error) = focus_result {
return Err(format!("cannot focus demo_http window: {error:?}"));
}
Ok(())
}
/// Returns a fresh snapshot of the HTTP endpoint pool.
#[tauri::command]
pub(crate) async fn demo_http_list_pool_clients(
state: tauri::State<'_, crate::KbAppState>,
) -> Result<std::vec::Vec<kb_lib::KbHttpPoolClientSnapshot>, std::string::String> {
Ok(state.http_pool.snapshot().await)
}
/// Executes one manual HTTP request through the endpoint pool.
#[tauri::command]
pub(crate) async fn demo_http_execute_request(
state: tauri::State<'_, crate::KbAppState>,
request: KbDemoHttpRequest,
) -> Result<KbDemoHttpExecutionPayload, std::string::String> {
let role = request.role.trim().to_string();
if role.is_empty() {
return Err("demo http role must not be empty".to_string());
}
let method = request.method.trim().to_string();
if method.is_empty() {
return Err("demo http method must not be empty".to_string());
}
let config_json_value_result = kb_parse_optional_demo_http_json(request.config_json);
let config_json_value = match config_json_value_result {
Ok(config_json_value) => config_json_value,
Err(error) => return Err(error),
};
let params_result =
kb_build_demo_http_params(&method, request.first_arg.as_deref(), config_json_value);
let params = match params_result {
Ok(params) => params,
Err(error) => return Err(error),
};
let selected_client_result = state
.http_pool
.select_client_for_role_and_method(&role, &method)
.await;
let selected_client = match selected_client_result {
Ok(selected_client) => selected_client,
Err(error) => return Err(error.to_string()),
};
let method_class = kb_lib::HttpClient::classify_method(&method);
let method_class_text = kb_demo_http_method_class_to_string(method_class);
tracing::info!(
endpoint_name = %selected_client.endpoint_name(),
endpoint_url = %selected_client.endpoint_url(),
role = %role,
method = %method,
method_class = %method_class_text,
"executing demo http request"
);
let response_value_result = selected_client
.execute_json_rpc_result_raw(method.clone(), params)
.await;
let response_value = match response_value_result {
Ok(response_value) => response_value,
Err(error) => return Err(error.to_string()),
};
let response_json_result = serde_json::to_string_pretty(&response_value);
let response_json = match response_json_result {
Ok(response_json) => response_json,
Err(error) => {
return Err(format!(
"cannot pretty-print demo http response for method '{}': {}",
method, error
));
}
};
Ok(KbDemoHttpExecutionPayload {
endpoint_name: selected_client.endpoint_name().to_string(),
provider: selected_client.endpoint_config().provider.clone(),
endpoint_url: selected_client.endpoint_url().to_string(),
role,
method,
method_class: method_class_text.to_string(),
response_json,
})
}
fn kb_parse_optional_demo_http_json(
config_json: std::option::Option<std::string::String>,
) -> Result<std::option::Option<serde_json::Value>, std::string::String> {
let config_json = match config_json {
Some(config_json) => config_json.trim().to_string(),
None => {
return Ok(None);
}
};
if config_json.is_empty() {
return Ok(None);
}
let value_result = serde_json::from_str::<serde_json::Value>(&config_json);
match value_result {
Ok(value) => Ok(Some(value)),
Err(error) => Err(format!("invalid configJson: {}", error)),
}
}
fn kb_build_demo_http_params(
method: &str,
first_arg: std::option::Option<&str>,
config_json: std::option::Option<serde_json::Value>,
) -> Result<std::vec::Vec<serde_json::Value>, std::string::String> {
let needs_first_arg = matches!(
method,
"getBalance"
| "getAccountInfo"
| "getProgramAccounts"
| "getSignaturesForAddress"
| "getTransaction"
| "sendTransaction"
);
if needs_first_arg {
let first_arg = match first_arg {
Some(first_arg) => first_arg.trim(),
None => "",
};
if first_arg.is_empty() {
return Err(format!("method '{}' requires firstArg", method));
}
let mut params = vec![serde_json::Value::String(first_arg.to_string())];
if let Some(config_json) = config_json {
params.push(config_json);
}
return Ok(params);
}
let mut params = std::vec::Vec::new();
if let Some(config_json) = config_json {
params.push(config_json);
}
Ok(params)
}
fn kb_demo_http_method_class_to_string(method_class: kb_lib::KbHttpMethodClass) -> &'static str {
match method_class {
kb_lib::KbHttpMethodClass::GeneralRpc => "GeneralRpc",
kb_lib::KbHttpMethodClass::SendTransaction => "SendTransaction",
kb_lib::KbHttpMethodClass::HeavyRead => "HeavyRead",
}
}

View File

@@ -383,7 +383,6 @@ pub(crate) async fn demo_ws_subscribe(
return Err("demo websocket client is not connected".to_string());
}
};
kb_execute_demo_ws_subscribe(&client, &request).await
}
@@ -445,7 +444,6 @@ async fn kb_execute_demo_ws_subscribe(
let filter = kb_parse_required_json_typed::<
solana_rpc_client_api::config::RpcBlockSubscribeFilter,
>(&request.filter_json, "block typed filter")?;
let config = kb_parse_optional_json_typed::<
solana_rpc_client_api::config::RpcBlockSubscribeConfig,
>(&request.config_json, "block typed config")?;

View File

@@ -9,8 +9,9 @@
#![deny(unreachable_pub)]
#![warn(missing_docs)]
mod splash;
mod demo_http;
mod demo_ws;
mod splash;
pub use crate::splash::SplashOrder;
use tauri::Emitter;
@@ -36,6 +37,7 @@ struct KbAppState {
config: kb_lib::KbConfig,
ws_runtime: tokio::sync::Mutex<KbWsRuntimeState>,
demo_ws_runtime: std::sync::Arc<tokio::sync::Mutex<crate::demo_ws::KbDemoWsRuntimeState>>,
http_pool: kb_lib::HttpEndpointPool,
}
/// Runs the desktop application.
@@ -72,12 +74,21 @@ pub fn run() {
environment = %config.app.environment,
"starting desktop application"
);
let http_pool_result = kb_lib::HttpEndpointPool::from_config(&config);
let http_pool = match http_pool_result {
Ok(http_pool) => http_pool,
Err(error) => {
tracing::error!("cannot create http endpoint pool: {}", error);
panic!("cannot create http endpoint pool: {}", error);
}
};
let app_state = KbAppState {
config: config.clone(),
ws_runtime: tokio::sync::Mutex::new(KbWsRuntimeState::new()),
demo_ws_runtime: std::sync::Arc::new(tokio::sync::Mutex::new(
crate::demo_ws::KbDemoWsRuntimeState::new(),
)),
http_pool,
};
let tracing_builder = tauri_plugin_tracing::Builder::new();
let mut tauri_builder = tauri::Builder::default();
@@ -91,7 +102,10 @@ pub fn run() {
crate::demo_ws::demo_ws_connect,
crate::demo_ws::demo_ws_disconnect,
crate::demo_ws::demo_ws_subscribe,
crate::demo_ws::demo_ws_unsubscribe_current
crate::demo_ws::demo_ws_unsubscribe_current,
crate::demo_http::open_demo_http_window,
crate::demo_http::demo_http_list_pool_clients,
crate::demo_http::demo_http_execute_request,
]);
tauri_builder = tauri_builder.plugin(tracing_builder.build::<tauri::Wry>());
tauri_builder = tauri_builder.setup(|app| {
@@ -147,7 +161,7 @@ pub fn run() {
emit_splash_order(&splash_window, "fadeout", None, None);
tracing::debug!("end splash fadeout");
tokio::time::sleep(std::time::Duration::from_millis(3100)).await;
let close_result = splash_window.close();
let close_result = splash_window.destroy();
if let Err(error) = close_result {
tracing::error!("error closing splash window: {error:?}");
}
@@ -351,13 +365,13 @@ fn kb_format_ws_event(event: &kb_lib::WsEvent) -> std::string::String {
endpoint_url,
} => {
format!("[ws:{endpoint_name}] connected to {endpoint_url}")
},
}
kb_lib::WsEvent::TextMessage {
endpoint_name,
text,
} => {
format!("[ws:{endpoint_name}] text: {text}")
},
}
kb_lib::WsEvent::JsonRpcMessage {
endpoint_name,
message,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "kb-bapp",
"version": "0.3.5",
"version": "0.4.4",
"identifier": "com.sasedev.kb-app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -47,6 +47,21 @@
"minHeight": 600,
"center": true,
"visible": false,
"create": false,
"transparent": false,
"decorations": true
},
{
"label": "demo_http",
"url": "demo_http.html",
"title": "Demo Http",
"width": 1100,
"height": 820,
"minWidth": 860,
"minHeight": 620,
"center": true,
"visible": false,
"create": false,
"transparent": false,
"decorations": true
}