add history visualization partitioned and ordered by lamport timestamp

This commit is contained in:
Arseniy Klempner 2025-04-01 19:53:25 -07:00
parent dc18ae685b
commit 283a0a389e
No known key found for this signature in database
GPG Key ID: 51653F18863BD24B
12 changed files with 562 additions and 55 deletions

View File

@ -206,7 +206,10 @@
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
overflow-y: scroll;
overflow-x: hidden;
min-width: 400px;
scrollbar-width: none;
}
.virtualizer-container {

View File

@ -3,11 +3,15 @@
import type { MessageChannelEventObject } from '$lib/sds/stream';
import { getMessageId } from '$lib/sds/message';
export let event: MessageChannelEventObject;
export let identicon: string;
export let event: MessageChannelEventObject | undefined = undefined;
export let identicon: string = '';
export let currentIdFilter: string | null = null;
export let onEventClick: (id: string | null) => void;
export let onDependencyClick: (messageId: string, event: Event) => void;
export let onEventClick: (id: string | null) => void = () => {};
export let onDependencyClick: (messageId: string, event: Event) => void = () => {};
export let width: number = 340;
export let height: number = 178;
export let overflow: boolean = true;
// Map event types to colors using index signature
const eventColors: { [key in string]: string } = {
@ -29,13 +33,15 @@
[MessageChannelEvent.MissedMessages]: 'Missed'
};
$: id = getMessageId(event);
$: color = eventColors[event.type] || '#888';
$: name = eventNames[event.type] || event.type;
$: id = event ? getMessageId(event) : null;
$: color = event ? (eventColors[event.type] || '#888') : '#f0f0f0';
$: name = event ? (eventNames[event.type] || event.type) : '';
$: matchesFilter = currentIdFilter && id === currentIdFilter;
function handleEventClick() {
onEventClick(id);
if (event && id) {
onEventClick(id);
}
}
function handleDependencyClick(messageId: string, e: Event) {
@ -43,65 +49,75 @@
}
</script>
<div class="history-item" on:click={handleEventClick}>
<div class="item-container">
<div
class="event-box {matchesFilter ? 'highlight' : ''}"
style="background-color: {color};"
>
<div class="identicon">
<img src="data:image/svg+xml;base64,{identicon}" alt="Identicon" />
<div class="history-item {!event ? 'empty' : ''}" style="width: 100%; height: {height}px;" on:click={event ? handleEventClick : undefined}>
{#if event}
<div class="item-container">
<div
class="event-box {matchesFilter ? 'highlight' : ''}"
style="background-color: {color};"
>
<div class="identicon">
<img src="data:image/svg+xml;base64,{identicon}" alt="Identicon" />
</div>
<div class="event-info" style="overflow: {overflow ? 'visible' : 'hidden'};">
<div class="event-type">
{name}
</div>
<div class="event-id">
{id}
</div>
</div>
{#if event.type === MessageChannelEvent.MessageDelivered}
<div class="sent-or-received">
{event.payload.sentOrReceived}
</div>
{/if}
{#if event.type === MessageChannelEvent.MessageSent || event.type === MessageChannelEvent.MessageReceived}
<div class="lamport-timestamp">
{event.payload.lamportTimestamp}
</div>
{/if}
</div>
<div class="event-info">
<div class="event-type">
{name}
</div>
<div class="event-id">
{id}
</div>
</div>
{#if event.type === MessageChannelEvent.MessageDelivered}
<div class="sent-or-received">
{event.payload.sentOrReceived}
</div>
{/if}
{#if event.type === MessageChannelEvent.MessageSent || event.type === MessageChannelEvent.MessageReceived}
<div class="lamport-timestamp">
{event.payload.lamportTimestamp}
</div>
{#each event.payload.causalHistory as dependency}
{@const dependencyMatchesFilter =
currentIdFilter && dependency.messageId === currentIdFilter}
<div
class="dependency-box {dependencyMatchesFilter ? 'highlight' : ''}"
style="background-color: {color};"
on:click={(e) => handleDependencyClick(dependency.messageId, e)}
>
{dependency.messageId}
</div>
{/each}
{/if}
</div>
{#if event.type === MessageChannelEvent.MessageSent || event.type === MessageChannelEvent.MessageReceived}
<!-- <section class="dependency-container"> -->
{#each event.payload.causalHistory as dependency}
{@const dependencyMatchesFilter =
currentIdFilter && dependency.messageId === currentIdFilter}
<div
class="dependency-box {dependencyMatchesFilter ? 'highlight' : ''}"
style="background-color: {color};"
on:click={(e) => handleDependencyClick(dependency.messageId, e)}
>
{dependency.messageId}
</div>
{/each}
<!-- </section> -->
{/if}
</div>
{/if}
</div>
<style>
.history-item {
padding: 8px;
width: 100%;
box-sizing: border-box;
}
.history-item:not(.empty) {
cursor: pointer;
}
.empty {
border: 1px dashed #ccc;
border-radius: 8px;
background-color: #f9f9f9;
}
.item-container {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
width: 100%;
height: 100%;
}
.event-box {
@ -135,6 +151,8 @@
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
overflow: hidden;
text-overflow: ellipsis;
}
.highlight {

View File

@ -0,0 +1,75 @@
<script lang="ts">
import type { MessageChannelEventObject } from '$lib/sds/stream';
import { grid } from '$lib/utils/stateGraph.svelte';
import HistoryItem from './HistoryItem.svelte';
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
// Props
export let identicons: Array<string> = [];
export let currentIdFilter: string | null = null;
export let onEventClick: (id: string | null) => void;
export let onDependencyClick: (messageId: string, event: Event) => void;
export let columns: number = 10; // Default number of columns
export let rows: number = 10; // Default number of rows
// Create 2D grid of items initialized with null (empty items)
onMount(() => {
// Initialize the state graph stream
});
onDestroy(() => {
// Clean up if needed
});
</script>
<div class="state-graph" style="--columns: {columns};">
{#each grid as row, y}
{#each row as item, x}
<div class="grid-item {item !== null ? 'filled' : ''}">
{#if item !== null && y * columns + x < identicons.length}
<HistoryItem
event={item}
identicon={identicons[y * columns + x]}
{currentIdFilter}
{onEventClick}
{onDependencyClick}
/>
{:else}
<HistoryItem />
{/if}
</div>
{/each}
{/each}
</div>
<style>
.state-graph {
display: grid;
grid-template-columns: repeat(var(--columns), 1fr);
gap: 10px;
width: 100%;
padding: 10px;
}
.grid-item {
width: 340px;
height: 178px;
justify-self: center;
}
.filled {
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@media (max-width: 1200px) {
.state-graph {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
}
</style>

View File

@ -0,0 +1,143 @@
<script lang="ts">
import { subscribeToAllEventsStream } from '$lib/sds/stream.svelte';
import { MessageChannelEvent } from '@waku/sds';
import { onMount, onDestroy } from 'svelte';
import { getIdenticon } from '$lib/identicon.svelte';
import { getMessageId } from '$lib/sds/message';
import type { MessageChannelEventObject } from '$lib/sds/stream';
import { grid, recordMessage } from '$lib/utils/stateGraph.svelte';
import { eventColors, eventNames } from '$lib/utils/event';
import HistoryItem from './HistoryItem.svelte';
// Store for history items
let history: Array<MessageChannelEventObject> = $state([]);
let identicons: {[messageId: string]: string} = $state({});
let identicon: any = $state(null);
const minHeight = 32;
const aspectRatio = 5.74;
let containerWidth: number;
let containerHeight: number;
const historyMutex = $state(false);
$effect(() => {
// Update container dimensions when grid changes
if (grid) {
containerWidth = grid[0]?.length || 0;
containerHeight = grid.length || 0;
}
});
// Unsubscribe function
let unsubscribe: (() => void) | null = $state(null);
// let identicons = $derived(
// identicon &&
// history.map((event: MessageChannelEventObject) => {
// const id = getMessageId(event);
// return new identicon(id || '', { size: 40, format: 'svg' }).toString();
// })
// );
onMount(async () => {
identicon = await getIdenticon();
// Subscribe to the event stream and collect events
unsubscribe = subscribeToAllEventsStream((event) => {
if (event.type === MessageChannelEvent.MissedMessages) {
return;
}
history = [event, ...history];
recordMessage(event);
const id = getMessageId(event);
if (!id) {
return;
}
if(identicons[id] === undefined) {
identicons[id] = new identicon(id, { size: 40, format: 'svg' }).toString();
}
});
});
onDestroy(() => {
if (unsubscribe) {
unsubscribe();
}
});
</script>
<div class="state-graph-container">
{#if grid}
<div class="grid-wrapper">
<div
class="grid"
style="--cell-height: {minHeight}px; --cell-width: {minHeight *
aspectRatio}px; --cols: {containerWidth}; --rows: {containerHeight}"
>
{#each grid as row}
<div class="row">
{#each row as cell}
<!-- {#if cell}
<div class="cell" style="background-color: {eventColors[cell.type]};">
<div class="cell-content" color={eventColors[cell.type]}>
{eventNames[cell.type]}
</div>
</div>
{/if} -->
{#if cell}
{@const id = getMessageId(cell)}
{#if id && identicons[id]}
<HistoryItem overflow={false} height={minHeight} width={minHeight * aspectRatio} identicon={identicons[id]} event={cell} />
{/if}
{/if}
{/each}
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.state-graph-container {
display: flex;
height: 100%;
width: 100%;
overflow: auto;
justify-content: center;
}
.grid-wrapper {
max-width: 100%;
max-height: 100%;
}
.grid {
display: flex;
grid-template-columns: repeat(var(--cols), var(--cell-width));
width: fit-content;
margin: auto;
}
.cell {
/* height: var(--cell-height);
width: var(--cell-width); */
max-width: var(--cell-width);
max-height: var(--cell-height);
border: 1px solid black;
background-color: rgba(170, 165, 209, 0.79);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.cell-content {
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 4px;
}
/* Remove unused styles */
</style>

View File

@ -0,0 +1,84 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
actual_grid,
update_virtual_grid,
shift_actual_grid
} from '$lib/utils/stateGraph.svelte';
import { subscribeToAllEventsStream } from '$lib/sds/stream.svelte';
import { MessageChannelEvent } from '@waku/sds';
import { eventColors, eventNames } from '$lib/utils/event';
onMount(() => {
console.log('StateGraphSummary mounted');
});
let unsubscribe: (() => void) | null = $state(null);
onMount(async () => {
unsubscribe = subscribeToAllEventsStream((event) => {
if (event.type === MessageChannelEvent.MissedMessages) {
return;
}
update_virtual_grid(event);
// shift_actual_grid();
});
});
onDestroy(() => {
if (unsubscribe) {
unsubscribe();
}
});
</script>
<div class="summary-grid">
{#each actual_grid as row}
<div class="column mw-200 mr-2 rounded-lg bg-none p-5 sm:shadow-md">
<p>{row.lamportTimestamp}</p>
{#each row.columns as cell}
{#if cell?.type}
<div class="cell" style="background-color: {eventColors[cell.type]};">
<p class="cell-text">{eventNames[cell.type]}</p>
</div>
{/if}
{/each}
</div>
{/each}
</div>
<style>
.summary-grid {
display: inline-flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
}
.cell {
min-width: 100px;
min-height: 50px;
border: 1px solid black;
margin: 1px;
align-content: center;
}
.empty-cell {
/* border: 1px solid black; */
border: none !important;
}
.column {
border: 1px solid black;
max-height: 400px;
max-width: 280px;
min-height: 200px;
min-width: 200px;
}
.cell-text {
font-size: 12px;
text-align: center;
vertical-align: middle;
text-transform: uppercase;
letter-spacing: 0.1em;
color: white;
}
</style>

View File

@ -73,7 +73,7 @@ async function send(payload: Uint8Array): Promise<void> {
console.error('error sending message', result.failures);
}
return {
success: result.successes.length > 0,
success: true,
retrievalHint: hash
};
});

View File

@ -0,0 +1,20 @@
import { MessageChannelEvent } from '@waku/sds';
export const eventColors: { [key in string]: string } = {
[MessageChannelEvent.MessageSent]: '#3B82F6', // blue
[MessageChannelEvent.MessageDelivered]: '#10B981', // green
[MessageChannelEvent.MessageReceived]: '#8B5CF6', // purple
[MessageChannelEvent.MessageAcknowledged]: '#059669', // dark green
[MessageChannelEvent.PartialAcknowledgement]: '#6D28D9', // dark purple
[MessageChannelEvent.MissedMessages]: '#EF4444' // red
};
// Event type to display name using index signature
export const eventNames: { [key in string]: string } = {
[MessageChannelEvent.MessageSent]: 'Sent',
[MessageChannelEvent.MessageDelivered]: 'Delivered',
[MessageChannelEvent.MessageReceived]: 'Received',
[MessageChannelEvent.MessageAcknowledged]: 'Acknowledged',
[MessageChannelEvent.PartialAcknowledgement]: 'Partially Acknowledged',
[MessageChannelEvent.MissedMessages]: 'Missed'
};

View File

@ -0,0 +1,123 @@
import { type MessageChannelEventObject } from '$lib/sds/stream';
import { MessageChannelEvent } from '@waku/sds';
const lamportTimestamp = $state(0);
let maxLamportTimestamp = $state(0);
export const initializeGrid = (_maxLamportTimestamp: number) => {
maxLamportTimestamp = _maxLamportTimestamp;
const rows = maxLamportTimestamp;
const columns = maxLamportTimestamp;
return createGrid(rows, columns);
};
export const addItems = (items: Array<MessageChannelEventObject>, _lamportTimestamp?: number) => {
if (!_lamportTimestamp) {
_lamportTimestamp = lamportTimestamp;
}
grid[_lamportTimestamp] = items;
};
export const createGrid = (
rows: number,
columns: number
): Array<Array<MessageChannelEventObject | null>> => {
return Array(rows)
.fill(null)
.map(() => Array(columns).fill(null));
};
type GridItem = {
lamportTimestamp: number;
events: Array<MessageChannelEventObject>;
};
const x_start = $state(0);
const x_window = 100;
const x_threshold = 0;
const virtual_grid: Map<number, GridItem> = $state(new Map());
export const actual_grid = $state(createGrid(x_window, 10).map((row, index) => ({lamportTimestamp: index, columns: row})));
export const update_virtual_grid = (event: MessageChannelEventObject) => {
const lamportTimestamp = getLamportTimestamp(event);
if (!lamportTimestamp) {
return;
}
const events = virtual_grid.get(lamportTimestamp)?.events || [];
events.push(event);
virtual_grid.set(lamportTimestamp, { lamportTimestamp, events });
if(lamportTimestamp > x_start + x_window - x_threshold) {
shift_actual_grid(0 ,lamportTimestamp - x_window - x_threshold);
} else if (x_start <= lamportTimestamp && lamportTimestamp <= x_start + x_window) {
actual_grid[lamportTimestamp % x_window].columns.push(event);
}
}
export const shift_actual_grid = (amount: number = 1, _x_start?: number) => {
if (!_x_start) {
_x_start = x_start;
}
for(let i = _x_start + amount; i < _x_start + x_window + amount; i++) {
const events = virtual_grid.get(i)?.events || [];
actual_grid[i % x_window] = {lamportTimestamp: i, columns: events};
}
}
export const grid = $state(createGrid(50, 10));
const getLamportTimestamp = (event: MessageChannelEventObject) => {
let lamportTimestamp = null;
if (
event.type === MessageChannelEvent.MessageSent ||
event.type === MessageChannelEvent.MessageReceived ||
event.type === MessageChannelEvent.SyncSent ||
event.type === MessageChannelEvent.SyncReceived
) {
lamportTimestamp = event.payload.lamportTimestamp;
if (!lamportTimestamp) {
return;
}
} else {
lamportTimestamp = longestTimestamp;
}
return lamportTimestamp;
}
const messagesPerLamportTimestamp = $state(new Map<number, Array<MessageChannelEventObject>>());
let longestTimestamp = $state(0);
export const recordMessage = (
message: MessageChannelEventObject,
_grid?: Array<Array<MessageChannelEventObject | null>>
) => {
if (!_grid) {
_grid = grid;
}
let lamportTimestamp = null;
if (
message.type === MessageChannelEvent.MessageSent ||
message.type === MessageChannelEvent.MessageReceived ||
message.type === MessageChannelEvent.SyncSent ||
message.type === MessageChannelEvent.SyncReceived
) {
lamportTimestamp = message.payload.lamportTimestamp;
if (!lamportTimestamp) {
return;
}
} else {
lamportTimestamp = longestTimestamp;
}
const messages = messagesPerLamportTimestamp.get(lamportTimestamp) || [];
messages.push(message);
messagesPerLamportTimestamp.set(lamportTimestamp, messages);
if (lamportTimestamp > longestTimestamp) {
longestTimestamp = lamportTimestamp;
}
const firstFill = _grid[lamportTimestamp].findIndex((item) => item !== null);
if (firstFill === -1) {
_grid[lamportTimestamp][Math.floor(_grid[lamportTimestamp].length / 2)] = message;
} else {
const lastFill = _grid[lamportTimestamp].findLastIndex((item) => item !== null);
if (firstFill > _grid[lamportTimestamp].length - lastFill) {
_grid[lamportTimestamp][firstFill - 1] = message;
} else {
_grid[lamportTimestamp][lastFill + 1] = message;
}
}
};

View File

@ -121,9 +121,8 @@ export async function startWaku(): Promise<void> {
// Connect to peers
await node.dial(
"/ip4/127.0.0.1/tcp/8000/ws/p2p/16Uiu2HAm6LgMnvadFttVeFsW5WHuoefsviCRbfo4AvnjySp4rnNt"
// "/dns4/node-01.do-ams3.waku.sandbox.status.im/tcp/8095/wss/p2p/16Uiu2HAmNaeL4p3WEYzC9mgXBmBWSgWjPHRvatZTXnp8Jgv3iKsb"
// '/dns4/waku-test.bloxy.one/tcp/8095/wss/p2p/16Uiu2HAmSZbDB7CusdRhgkD81VssRjQV5ZH13FbzCGcdnbbh6VwZ'
"/ip4/127.0.0.1/tcp/8000/ws/p2p/16Uiu2HAm3TLea2NVs4dAqYM2gAgoV9CMKGeD1BkP3RAvmk7HBAbU"
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).waku = node;

View File

@ -18,7 +18,7 @@
<ActionModule />
<ConnectionIndicator />
</Header>
<div class="flex-1 overflow-auto">
<div class="flex-1 overflow-auto my-1">
<slot />
</div>
</div>

View File

@ -7,7 +7,7 @@
// Redirect to history page when connected
$effect(() => {
if ($connectionState.status === "connected") {
goto('/history');
goto('/state-graph');
}
});
</script>

View File

@ -0,0 +1,42 @@
<script lang="ts">
import History from '$lib/components/History.svelte';
import StateGraphDashboard from '$lib/components/StateGraphDashboard.svelte';
import StateGraphSummary from '$lib/components/StateGraphSummary.svelte';
</script>
<div class="mx-1 flex h-full flex-row" style="overflow: hidden;">
<!-- History Sidebar -->
<div
class="top-0 right-0 left-0 mr-1 rounded-lg bg-white sm:shadow-md"
style="
background-color: rgb(201 201 201);
display: flex;
"
>
<History />
</div>
<div class="flex w-full flex-col overflow-hidden">
<!-- Summary State Graph -->
<div
class="top-0 right-0 left-0 mb-1 h-full w-full flex-5 basis-3/4 rounded-lg bg-white p-8 sm:shadow-md"
style="
background-color: rgb(182 195 206);
align-content: center;
overflow: auto;
"
>
<StateGraphSummary />
</div>
<!-- Detailed State Graph
<div
class="top-0 right-0 left-0 basis-1/4 rounded-lg bg-white p-1 sm:shadow-md"
style="
background-color: rgb(151 174 194);
width: 100%;
"
>
<StateGraphDashboard />
</div> -->
</div>
</div>