From 2636da4ba015af20f60abfee5da2002ba06da5a0 Mon Sep 17 00:00:00 2001 From: Sasha <118575614+weboko@users.noreply.github.com> Date: Mon, 25 Aug 2025 23:40:22 +0200 Subject: [PATCH] feat: add charts to dogfooding app (#141) --- examples/dogfooding/index.html | 64 +++++++- examples/dogfooding/package-lock.json | 48 ++++++ examples/dogfooding/package.json | 2 + examples/dogfooding/public/style.css | 37 +++++ examples/dogfooding/src/index.ts | 19 +++ examples/dogfooding/src/ui-manager.ts | 198 +++++++++++++++++++++++- examples/dogfooding/src/waku-service.ts | 103 ++++++++++++ 7 files changed, 468 insertions(+), 3 deletions(-) diff --git a/examples/dogfooding/index.html b/examples/dogfooding/index.html index d9682e7..4707cab 100644 --- a/examples/dogfooding/index.html +++ b/examples/dogfooding/index.html @@ -51,9 +51,69 @@ +
+

Discovery Peers Over Time

+ +
+
+

Show Discovery Table

+ +
+ +
+ + + +
+

Connected Peers Over Time

+ +
+
+

Show Connections Table

+ +
+ +
+ +
+

Message Delivery Latency

+ +
+
+

Latency Table

+ +
+ +
+
-

Message Log

-
+
+

Message Log

+ +
+
diff --git a/examples/dogfooding/package-lock.json b/examples/dogfooding/package-lock.json index 62a6758..76aeec2 100644 --- a/examples/dogfooding/package-lock.json +++ b/examples/dogfooding/package-lock.json @@ -11,6 +11,8 @@ "@libp2p/crypto": "^5.0.5", "@multiformats/multiaddr": "^12.3.1", "@waku/sdk": "0.0.34", + "chart.js": "^4.4.1", + "chartjs-plugin-zoom": "^2.0.1", "libp2p": "^2.1.10", "protobufjs": "^7.3.0", "uint8arrays": "^5.1.0" @@ -331,6 +333,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -995,6 +1003,12 @@ "@types/send": "*" } }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -2749,6 +2763,31 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-plugin-zoom": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", + "integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.45", + "hammerjs": "^2.0.8" + }, + "peerDependencies": { + "chart.js": ">=3.2.0" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -4822,6 +4861,15 @@ "dev": true, "license": "MIT" }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", diff --git a/examples/dogfooding/package.json b/examples/dogfooding/package.json index 9d063b4..3455b12 100644 --- a/examples/dogfooding/package.json +++ b/examples/dogfooding/package.json @@ -10,6 +10,8 @@ "@libp2p/crypto": "^5.0.5", "@multiformats/multiaddr": "^12.3.1", "@waku/sdk": "0.0.34", + "chart.js": "^4.4.1", + "chartjs-plugin-zoom": "^2.0.1", "libp2p": "^2.1.10", "protobufjs": "^7.3.0", "uint8arrays": "^5.1.0" diff --git a/examples/dogfooding/public/style.css b/examples/dogfooding/public/style.css index d10d7e8..2980905 100644 --- a/examples/dogfooding/public/style.css +++ b/examples/dogfooding/public/style.css @@ -115,6 +115,43 @@ h2 { padding: 10px; } +.collapsible-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.collapsible.hidden { + display: none; +} + +table { + width: 100%; + border-collapse: collapse; +} + +table th, table td { + padding: 8px; + border-bottom: 1px solid #ecf0f1; + text-align: left; + font-size: 0.9em; +} + +.latency-summary { + margin-top: 8px; + margin-bottom: 8px; + font-weight: 600; + color: #34495e; +} + +/* Scrollable tables */ +#discoveryTableContainer, +#connectionsTableContainer, +#latencyTableContainer { + max-height: 260px; + overflow-y: auto; +} + .message-item { padding: 10px; margin-bottom: 8px; diff --git a/examples/dogfooding/src/index.ts b/examples/dogfooding/src/index.ts index f1ed3ad..c267b6c 100644 --- a/examples/dogfooding/src/index.ts +++ b/examples/dogfooding/src/index.ts @@ -20,6 +20,13 @@ import { addMessageToLog, renderMessages, getSearchTerm, + initCharts, + onDiscoveryUpdate, + onConnectionsUpdate, + wireUiToggles, + trackMessageSent, + trackMessageReceived, + recordLatency, } from "./ui-manager"; const NUM_MESSAGES_PER_BATCH = 5; @@ -66,6 +73,7 @@ async function initializeApp() { console.log(`Message ${i + 1} (ID: ${chatMessage.id}) sent successfully.`); incrementSentByMe(); addMessageToLog(chatMessage, 'sent'); + trackMessageSent(chatMessage.id, chatMessage.timestamp); } else { console.warn(`Failed to send message ${i + 1} (ID: ${chatMessage.id}):`, result.failures); const failureReason = result.failures.length > 0 @@ -133,6 +141,7 @@ async function initializeApp() { console.log(`Continuous message (ID: ${chatMessage.id}) sent successfully.`); incrementSentByMe(); addMessageToLog(chatMessage, 'sent'); + trackMessageSent(chatMessage.id, chatMessage.timestamp); } else { console.warn(`Failed to send continuous message (ID: ${chatMessage.id}):`, result.failures); } @@ -184,6 +193,12 @@ async function initializeApp() { addMessageToLog(chatMessage, 'received-other'); console.log("Received message from other peer:", chatMessage.id); } + // Use encoded timestamp when available for more accurate latency + if (chatMessage.timestamp) { + recordLatency(chatMessage.id, chatMessage.timestamp, Date.now()); + } else { + trackMessageReceived(chatMessage.id, Date.now()); + } } else { console.warn("Could not decode received Waku message. Payload might be malformed or not a ChatMessage."); } @@ -226,6 +241,10 @@ async function initializeApp() { }); } + initCharts(); + (window as any).onDiscoveryUpdate = onDiscoveryUpdate; + (window as any).onConnectionsUpdate = onConnectionsUpdate; + wireUiToggles(); await subscribeToMessages(); console.log("Application setup complete. Click 'Send New Message Batch' to send messages."); diff --git a/examples/dogfooding/src/ui-manager.ts b/examples/dogfooding/src/ui-manager.ts index b545628..04743c8 100644 --- a/examples/dogfooding/src/ui-manager.ts +++ b/examples/dogfooding/src/ui-manager.ts @@ -1,4 +1,9 @@ import { ChatMessage } from "./message-service"; +import { getNodeCreationTime } from "./waku-service"; +import Chart from "chart.js/auto"; +// @ts-ignore - plugin has no types in our env +import zoomPlugin from "chartjs-plugin-zoom"; +Chart.register(zoomPlugin as any); const sentByMeCountEl = document.getElementById("sentByMeCount") as HTMLSpanElement; const receivedMineCountEl = document.getElementById("receivedMineCount") as HTMLSpanElement; @@ -13,9 +18,24 @@ let receivedMine = 0; let receivedOthers = 0; let failedToSend = 0; -let currentMessages: ChatMessage[] = []; +const currentMessages: ChatMessage[] = []; let currentPeerId: string | undefined; +const discoveryChartEl = document.getElementById("discoveryChart") as HTMLCanvasElement | null; +const connectionsChartEl = document.getElementById("connectionsChart") as HTMLCanvasElement | null; +const latencyChartEl = document.getElementById("latencyChart") as HTMLCanvasElement | null; + +let discoveryChart: Chart | null = null; +let connectionsChart: Chart | null = null; +let latencyChart: Chart | null = null; + +const discoveryTableBody = document.querySelector("#discoveryTable tbody") as HTMLTableSectionElement | null; +const connectionsTableBody = document.querySelector("#connectionsTable tbody") as HTMLTableSectionElement | null; +const latencyTableBody = document.querySelector("#latencyTable tbody") as HTMLTableSectionElement | null; +const latencySummaryTopEl = document.getElementById("latencySummaryTop") as HTMLDivElement | null; +const ttfcSummaryEl = document.getElementById("ttfcSummary") as HTMLDivElement | null; +const discoverySummaryTopEl = document.getElementById("discoverySummaryTop") as HTMLDivElement | null; + export function updatePeerIdDisplay(peerId: string) { currentPeerId = peerId; if (peerIdDisplayEl) { @@ -126,3 +146,179 @@ export function renderMessages(filterText?: string) { export function getSearchTerm(): string { return searchInputEl ? searchInputEl.value : ""; } + +function toRelTime(ms: number) { + const start = getNodeCreationTime(); + return (ms - start) / 1000; +} + +export function setupCollapsibles() { + const pairs: Array<[string, string]> = [ + ["toggleLog", "messageLogContainer"], + ["toggleDiscoveryTable", "discoveryTableContainer"], + ["toggleConnectionsTable", "connectionsTableContainer"], + ["toggleLatencyTable", "latencyTableContainer"], + ]; + pairs.forEach(([headerId, containerId]) => { + const header = document.getElementById(headerId); + const container = document.getElementById(containerId); + const btn = header?.querySelector("button"); + if (!header || !container || !btn) return; + btn.addEventListener("click", () => { + const hidden = container.classList.toggle("hidden"); + btn.textContent = hidden ? "Show" : "Hide"; + }); + }); +} + +export function initCharts() { + if (discoveryChartEl) { + discoveryChart = new Chart(discoveryChartEl, { + type: "line", + data: { + datasets: [ + { label: "bootstrap", data: [], borderColor: "#1abc9c", tension: 0.2, pointRadius: 2, borderWidth: 2 }, + { label: "peer-exchange", data: [], borderColor: "#e67e22", tension: 0.2, pointRadius: 2, borderWidth: 2 }, + { label: "peer-cache", data: [], borderColor: "#9b59b6", tension: 0.2, pointRadius: 2, borderWidth: 2 }, + ], + }, + options: { scales: { x: { type: 'linear', title: { display: true, text: "time (s)" } }, y: { title: { display: true, text: "peers" } } } }, + }); + } + if (connectionsChartEl) { + connectionsChart = new Chart(connectionsChartEl, { + type: "line", + data: { datasets: [{ label: "connections", data: [], borderColor: "#2980b9", tension: 0.25, pointRadius: 2, borderWidth: 2 }] }, + options: { + scales: { x: { type: 'linear', title: { display: true, text: "time (s)" } }, y: { title: { display: true, text: "peers" } } }, + plugins: { + zoom: { + zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }, + pan: { enabled: true, mode: 'x' } + } + } + }, + }); + } + if (latencyChartEl) { + latencyChart = new Chart(latencyChartEl, { + type: "bar", + data: { labels: [], datasets: [{ label: "latency (ms)", data: [], backgroundColor: "#34495e" }] }, + options: { scales: { x: { type: 'category', title: { display: true, text: "message" } }, y: { title: { display: true, text: "ms" } } } }, + }); + } +} + +export function onDiscoveryUpdate(timeline: Array<{ time: number; type: string; total: number }>) { + if (!discoveryChart || !discoveryTableBody) return; + const grouped: Record> = {}; + discoveryChart.data.datasets?.forEach(ds => { grouped[ds.label as string] = []; }); + timeline.forEach(ev => { + if (!grouped[ev.type]) grouped[ev.type] = []; + grouped[ev.type].push({ x: toRelTime(ev.time), y: ev.total }); + }); + discoveryChart.data.datasets?.forEach(ds => { + const label = ds.label as string; + // @ts-ignore + ds.data = grouped[label] || []; + }); + discoveryChart.update(); + + if (discoverySummaryTopEl) { + const firsts: Record = { + "bootstrap": undefined, + "peer-exchange": undefined, + "peer-cache": undefined, + }; + for (const ev of timeline) { + if (firsts[ev.type] === undefined) firsts[ev.type] = ev.time; + } + const fmt = (t?: number) => t !== undefined ? `${toRelTime(t).toFixed(2)}s` : "-"; + discoverySummaryTopEl.textContent = `first bootstrap: ${fmt(firsts["bootstrap"])}, peer-exchange: ${fmt(firsts["peer-exchange"])}, peer-cache: ${fmt(firsts["peer-cache"])}`; + } + + discoveryTableBody.innerHTML = ""; + timeline.slice(-100).forEach(ev => { + const tr = document.createElement("tr"); + const tdTime = document.createElement("td"); + tdTime.textContent = new Date(ev.time).toLocaleTimeString(); + const tdType = document.createElement("td"); + tdType.textContent = ev.type; + const tdTotal = document.createElement("td"); + tdTotal.textContent = String(ev.total); + tr.appendChild(tdTime); tr.appendChild(tdType); tr.appendChild(tdTotal); + discoveryTableBody.appendChild(tr); + }); +} + +export function onConnectionsUpdate(timeline: Array<{ time: number; total: number }>) { + if (!connectionsChart || !connectionsTableBody) return; + const data = timeline.map(ev => ({ x: toRelTime(ev.time), y: ev.total })); + // @ts-ignore + connectionsChart.data.datasets[0].data = data; + connectionsChart.update(); + + if (ttfcSummaryEl && timeline.length > 0) { + const first = timeline[0]; + const seconds = toRelTime(first.time); + ttfcSummaryEl.textContent = `time to first connection: ${seconds.toFixed(2)}s`; + } + + connectionsTableBody.innerHTML = ""; + timeline.slice(-100).forEach(ev => { + const tr = document.createElement("tr"); + const tdTime = document.createElement("td"); + tdTime.textContent = new Date(ev.time).toLocaleTimeString(); + const tdTotal = document.createElement("td"); + tdTotal.textContent = String(ev.total); + tr.appendChild(tdTime); tr.appendChild(tdTotal); + connectionsTableBody.appendChild(tr); + }); +} + +const sentTimestamps = new Map(); + +export function trackMessageSent(id: string, when: number) { + sentTimestamps.set(id, when); +} + +export function trackMessageReceived(id: string, when: number) { + const sent = sentTimestamps.get(id); + if (sent === undefined) return; + const latency = Math.max(0, when - sent); + if (latencyChart && latencyTableBody) { + // push label + numeric value for bar chart + const label = id.slice(-6); + latencyChart.data.labels?.push(label); + // @ts-ignore + latencyChart.data.datasets[0].data.push(latency); + latencyChart.update(); + + const tr = document.createElement("tr"); + const tdId = document.createElement("td"); tdId.textContent = id; + const tdSent = document.createElement("td"); tdSent.textContent = new Date(sent).toLocaleTimeString(); + const tdRecv = document.createElement("td"); tdRecv.textContent = new Date(when).toLocaleTimeString(); + const tdLat = document.createElement("td"); tdLat.textContent = String(latency); + tr.appendChild(tdId); tr.appendChild(tdSent); tr.appendChild(tdRecv); tr.appendChild(tdLat); + latencyTableBody.appendChild(tr); + + const values: number[] = (latencyChart.data.datasets[0].data as any[]).map((v: any) => Number(v)); + if (latencySummaryTopEl) { + const avg = values.reduce((a, b) => a + b, 0) / values.length; + const sorted = [...values].sort((a, b) => a - b); + const p = (q: number) => sorted[Math.floor((q / 100) * (sorted.length - 1))] ?? 0; + latencySummaryTopEl.textContent = `avg=${avg.toFixed(1)}ms p90=${p(90)}ms p95=${p(95)}ms p99=${p(99)}ms`; + } + } +} + +// In case a message is received before we recorded its send time (tab reload, etc.) +export function recordLatency(id: string, sent: number, received?: number) { + const when = received ?? Date.now(); + sentTimestamps.set(id, sent); + trackMessageReceived(id, when); +} + +export function wireUiToggles() { + setupCollapsibles(); +} diff --git a/examples/dogfooding/src/waku-service.ts b/examples/dogfooding/src/waku-service.ts index 2d8d23a..5dd1dfe 100644 --- a/examples/dogfooding/src/waku-service.ts +++ b/examples/dogfooding/src/waku-service.ts @@ -6,6 +6,35 @@ import { sha256, generateRandomNumber } from "./utils"; export const DEFAULT_CONTENT_TOPIC = "/js-waku-examples/1/message-ratio/utf8"; let wakuNodeInstance: LightNode | null = null; +let nodeCreationTime = Date.now(); + +export type DiscoveryType = "bootstrap" | "peer-exchange" | "peer-cache"; + +export interface DiscoveryEvent { + time: number; + type: DiscoveryType; + total: number; +} + +export interface ConnectionEvent { + time: number; + total: number; +} + +const discoveryCounts: Record = { + "bootstrap": 0, + "peer-exchange": 0, + "peer-cache": 0, +}; + +const discoveredAt = new Map(); +const countedByType: Record> = { + "bootstrap": new Set(), + "peer-exchange": new Set(), + "peer-cache": new Set(), +}; +const connectionTimeline: ConnectionEvent[] = []; +const discoveryTimeline: DiscoveryEvent[] = []; export async function getWakuNode(): Promise { if (wakuNodeInstance) { @@ -38,6 +67,68 @@ export async function getWakuNode(): Promise { await node.start(); await node.waitForPeers(); + nodeCreationTime = Date.now(); + + node.libp2p.addEventListener("peer:discovery", (evt: any) => { + try { + const info = evt.detail; // PeerInfo + const peerId = info?.id?.toString?.() || info?.id || ""; + if (!peerId) return; + if (!discoveredAt.has(peerId)) { + discoveredAt.set(peerId, Date.now()); + } + } catch (_) {} + }); + + node.libp2p.addEventListener("peer:update", (evt: any) => { + try { + const update = evt.detail; // PeerUpdate + const peer = update?.peer; + const peerId = peer?.id?.toString?.() || peer?.id || ""; + if (!peerId) return; + const peerTags = peer?.tags ?? {}; + let hasBootstrap = false; + let hasPeerExchange = false; + let hasPeerCache = false; + if (peerTags instanceof Map) { + hasBootstrap = peerTags.has("bootstrap"); + hasPeerExchange = peerTags.has("peer-exchange"); + hasPeerCache = peerTags.has("peer-cache"); + } else { + hasBootstrap = Object.prototype.hasOwnProperty.call(peerTags, "bootstrap"); + hasPeerExchange = Object.prototype.hasOwnProperty.call(peerTags, "peer-exchange"); + hasPeerCache = Object.prototype.hasOwnProperty.call(peerTags, "peer-cache"); + } + + const time = discoveredAt.get(peerId) ?? Date.now(); + + if (hasBootstrap && !countedByType["bootstrap"].has(peerId)) { + countedByType["bootstrap"].add(peerId); + discoveryCounts["bootstrap"]++; + discoveryTimeline.push({ time, type: "bootstrap", total: discoveryCounts["bootstrap"] }); + } + if (hasPeerExchange && !countedByType["peer-exchange"].has(peerId)) { + countedByType["peer-exchange"].add(peerId); + discoveryCounts["peer-exchange"]++; + discoveryTimeline.push({ time, type: "peer-exchange", total: discoveryCounts["peer-exchange"] }); + } + if (hasPeerCache && !countedByType["peer-cache"].has(peerId)) { + countedByType["peer-cache"].add(peerId); + discoveryCounts["peer-cache"]++; + discoveryTimeline.push({ time, type: "peer-cache", total: discoveryCounts["peer-cache"] }); + } + + (window as any).onDiscoveryUpdate?.([...discoveryTimeline]); + } catch (_) {} + }); + + node.libp2p.addEventListener("peer:connect", () => { + const now = Date.now(); + const total = node.libp2p.getConnections().length; + connectionTimeline.push({ time: now, total }); + (window as any).onConnectionsUpdate?.([...connectionTimeline]); + }); + wakuNodeInstance = node; return node; } @@ -57,3 +148,15 @@ export function createWakuDecoder() { contentTopic: DEFAULT_CONTENT_TOPIC, }); } + +export function getDiscoveryTimeline() { + return discoveryTimeline; +} + +export function getConnectionTimeline() { + return connectionTimeline; +} + +export function getNodeCreationTime() { + return nodeCreationTime; +}