feat: add charts to dogfooding app (#141)

This commit is contained in:
Sasha 2025-08-25 23:40:22 +02:00 committed by GitHub
parent f7196a4bf0
commit 2636da4ba0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 468 additions and 3 deletions

View File

@ -51,9 +51,69 @@
</div>
</section>
<section class="charts">
<h2>Discovery Peers Over Time</h2>
<canvas id="discoveryChart"></canvas>
<div id="discoverySummaryTop" class="latency-summary"></div>
<div class="collapsible-header" id="toggleDiscoveryTable">
<h3>Show Discovery Table</h3>
<button id="toggleDiscoveryTableBtn" class="btn">Show</button>
</div>
<div id="discoveryTableContainer" class="collapsible hidden">
<table id="discoveryTable">
<thead>
<tr><th>Time</th><th>Type</th><th>Total Peers</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="charts">
<h2>Connected Peers Over Time</h2>
<canvas id="connectionsChart"></canvas>
<div id="ttfcSummary" class="latency-summary"></div>
<div class="collapsible-header" id="toggleConnectionsTable">
<h3>Show Connections Table</h3>
<button id="toggleConnectionsTableBtn" class="btn">Show</button>
</div>
<div id="connectionsTableContainer" class="collapsible hidden">
<table id="connectionsTable">
<thead>
<tr><th>Time</th><th>Connected Peers</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="charts">
<h2>Message Delivery Latency</h2>
<canvas id="latencyChart"></canvas>
<div id="latencySummaryTop" class="latency-summary"></div>
<div class="collapsible-header" id="toggleLatencyTable">
<h3>Latency Table</h3>
<button id="toggleLatencyTableBtn" class="btn">Show</button>
</div>
<div id="latencyTableContainer" class="collapsible hidden">
<table id="latencyTable">
<thead>
<tr><th>Message ID</th><th>Sent</th><th>Received</th><th>Latency (ms)</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="message-display">
<h2>Message Log</h2>
<div id="messageList" class="message-list">
<div class="collapsible-header" id="toggleLog">
<h2>Message Log</h2>
<button id="toggleLogBtn" class="btn">Show</button>
</div>
<div id="messageLogContainer" class="collapsible hidden">
<div id="messageList" class="message-list"></div>
</div>
</section>
</main>

View File

@ -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",

View File

@ -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"

View File

@ -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;

View File

@ -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.");

View File

@ -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<string, Array<{ x: number; y: number }>> = {};
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<string, number | undefined> = {
"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<string, number>();
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();
}

View File

@ -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<DiscoveryType, number> = {
"bootstrap": 0,
"peer-exchange": 0,
"peer-cache": 0,
};
const discoveredAt = new Map<string, number>();
const countedByType: Record<DiscoveryType, Set<string>> = {
"bootstrap": new Set<string>(),
"peer-exchange": new Set<string>(),
"peer-cache": new Set<string>(),
};
const connectionTimeline: ConnectionEvent[] = [];
const discoveryTimeline: DiscoveryEvent[] = [];
export async function getWakuNode(): Promise<LightNode> {
if (wakuNodeInstance) {
@ -38,6 +67,68 @@ export async function getWakuNode(): Promise<LightNode> {
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;
}