Add viewer network statistics panel
This commit is contained in:
@@ -46,6 +46,24 @@ interface WorldState {
|
|||||||
recentEvents: SimulationEventRecord[];
|
recentEvents: SimulationEventRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NetworkSample {
|
||||||
|
atMs: number;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkStats {
|
||||||
|
snapshotBytes: number;
|
||||||
|
deltasReceived: number;
|
||||||
|
deltaBytes: number;
|
||||||
|
lastDeltaBytes: number;
|
||||||
|
lastEntityChanges: number;
|
||||||
|
eventsReceived: number;
|
||||||
|
streamConnected: boolean;
|
||||||
|
streamOpenedAtMs?: number;
|
||||||
|
lastDeltaAtMs?: number;
|
||||||
|
throughputSamples: NetworkSample[];
|
||||||
|
}
|
||||||
|
|
||||||
export class GameViewer {
|
export class GameViewer {
|
||||||
private readonly container: HTMLElement;
|
private readonly container: HTMLElement;
|
||||||
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
|
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
@@ -67,11 +85,22 @@ export class GameViewer {
|
|||||||
private readonly detailTitleEl: HTMLHeadingElement;
|
private readonly detailTitleEl: HTMLHeadingElement;
|
||||||
private readonly detailBodyEl: HTMLDivElement;
|
private readonly detailBodyEl: HTMLDivElement;
|
||||||
private readonly factionStripEl: HTMLDivElement;
|
private readonly factionStripEl: HTMLDivElement;
|
||||||
|
private readonly networkPanelEl: HTMLDivElement;
|
||||||
private readonly resetButton: HTMLButtonElement;
|
private readonly resetButton: HTMLButtonElement;
|
||||||
private readonly errorEl: HTMLDivElement;
|
private readonly errorEl: HTMLDivElement;
|
||||||
private readonly streamEl: HTMLDivElement;
|
private readonly streamEl: HTMLDivElement;
|
||||||
private world?: WorldState;
|
private world?: WorldState;
|
||||||
private stream?: EventSource;
|
private stream?: EventSource;
|
||||||
|
private readonly networkStats: NetworkStats = {
|
||||||
|
snapshotBytes: 0,
|
||||||
|
deltasReceived: 0,
|
||||||
|
deltaBytes: 0,
|
||||||
|
lastDeltaBytes: 0,
|
||||||
|
lastEntityChanges: 0,
|
||||||
|
eventsReceived: 0,
|
||||||
|
streamConnected: false,
|
||||||
|
throughputSamples: [],
|
||||||
|
};
|
||||||
private selected?: Selectable;
|
private selected?: Selectable;
|
||||||
private dragging = false;
|
private dragging = false;
|
||||||
private lastPointer = new THREE.Vector2();
|
private lastPointer = new THREE.Vector2();
|
||||||
@@ -113,6 +142,10 @@ export class GameViewer {
|
|||||||
<div class="detail-body">Waiting for the authoritative snapshot.</div>
|
<div class="detail-body">Waiting for the authoritative snapshot.</div>
|
||||||
<div class="error-strip" hidden></div>
|
<div class="error-strip" hidden></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
<aside class="network-panel">
|
||||||
|
<h2>Network</h2>
|
||||||
|
<div class="network-body">Waiting for snapshot.</div>
|
||||||
|
</aside>
|
||||||
<section class="faction-strip"></section>
|
<section class="faction-strip"></section>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -121,6 +154,7 @@ export class GameViewer {
|
|||||||
this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
|
this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
|
||||||
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
|
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
|
||||||
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
|
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
|
||||||
|
this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement;
|
||||||
this.resetButton = hud.querySelector(".reset-button") as HTMLButtonElement;
|
this.resetButton = hud.querySelector(".reset-button") as HTMLButtonElement;
|
||||||
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
|
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
|
||||||
|
|
||||||
@@ -146,6 +180,7 @@ export class GameViewer {
|
|||||||
try {
|
try {
|
||||||
const snapshot = await fetchWorldSnapshot();
|
const snapshot = await fetchWorldSnapshot();
|
||||||
this.world = this.createWorldState(snapshot);
|
this.world = this.createWorldState(snapshot);
|
||||||
|
this.networkStats.snapshotBytes = new Blob([JSON.stringify(snapshot)]).size;
|
||||||
this.statusEl.textContent = `Snapshot ${snapshot.sequence}`;
|
this.statusEl.textContent = `Snapshot ${snapshot.sequence}`;
|
||||||
this.errorEl.hidden = true;
|
this.errorEl.hidden = true;
|
||||||
this.applySnapshot(snapshot);
|
this.applySnapshot(snapshot);
|
||||||
@@ -163,18 +198,23 @@ export class GameViewer {
|
|||||||
this.stream?.close();
|
this.stream?.close();
|
||||||
this.stream = openWorldStream(afterSequence, {
|
this.stream = openWorldStream(afterSequence, {
|
||||||
onOpen: () => {
|
onOpen: () => {
|
||||||
|
this.networkStats.streamConnected = true;
|
||||||
|
this.networkStats.streamOpenedAtMs = performance.now();
|
||||||
this.streamEl.textContent = "Stream Live";
|
this.streamEl.textContent = "Stream Live";
|
||||||
|
this.updateNetworkPanel();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
this.networkStats.streamConnected = false;
|
||||||
this.streamEl.textContent = "Stream Reconnecting";
|
this.streamEl.textContent = "Stream Reconnecting";
|
||||||
|
this.updateNetworkPanel();
|
||||||
},
|
},
|
||||||
onDelta: (delta) => {
|
onDelta: (delta, rawBytes) => {
|
||||||
void this.handleDelta(delta);
|
void this.handleDelta(delta, rawBytes);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleDelta(delta: WorldDelta) {
|
private async handleDelta(delta: WorldDelta, rawBytes: number) {
|
||||||
if (!this.world) {
|
if (!this.world) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -185,8 +225,10 @@ export class GameViewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.applyDelta(delta);
|
this.applyDelta(delta);
|
||||||
|
this.recordDeltaStats(delta, rawBytes);
|
||||||
this.statusEl.textContent = `Seq ${delta.sequence} · ${new Date(delta.generatedAtUtc).toLocaleTimeString()}`;
|
this.statusEl.textContent = `Seq ${delta.sequence} · ${new Date(delta.generatedAtUtc).toLocaleTimeString()}`;
|
||||||
this.updatePanels();
|
this.updatePanels();
|
||||||
|
this.updateNetworkPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleReset() {
|
private async handleReset() {
|
||||||
@@ -194,9 +236,11 @@ export class GameViewer {
|
|||||||
try {
|
try {
|
||||||
const snapshot = await resetWorld();
|
const snapshot = await resetWorld();
|
||||||
this.world = this.createWorldState(snapshot);
|
this.world = this.createWorldState(snapshot);
|
||||||
|
this.networkStats.snapshotBytes = new Blob([JSON.stringify(snapshot)]).size;
|
||||||
this.applySnapshot(snapshot);
|
this.applySnapshot(snapshot);
|
||||||
this.openDeltaStream(snapshot.sequence);
|
this.openDeltaStream(snapshot.sequence);
|
||||||
this.updatePanels();
|
this.updatePanels();
|
||||||
|
this.updateNetworkPanel();
|
||||||
} finally {
|
} finally {
|
||||||
this.resetButton.disabled = false;
|
this.resetButton.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -229,6 +273,7 @@ export class GameViewer {
|
|||||||
this.syncStations(snapshot.stations);
|
this.syncStations(snapshot.stations);
|
||||||
this.syncShips(snapshot.ships, snapshot.tickIntervalMs);
|
this.syncShips(snapshot.ships, snapshot.tickIntervalMs);
|
||||||
this.rebuildFactions(snapshot.factions);
|
this.rebuildFactions(snapshot.factions);
|
||||||
|
this.updateNetworkPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyDelta(delta: WorldDelta) {
|
private applyDelta(delta: WorldDelta) {
|
||||||
@@ -515,9 +560,51 @@ export class GameViewer {
|
|||||||
this.camera.position.lerp(new THREE.Vector3(this.focus.x + 2200, 1600, this.focus.z + 2200), Math.min(1, delta * 2));
|
this.camera.position.lerp(new THREE.Vector3(this.focus.x + 2200, 1600, this.focus.z + 2200), Math.min(1, delta * 2));
|
||||||
this.camera.lookAt(this.focus);
|
this.camera.lookAt(this.focus);
|
||||||
this.updateShipPresentation();
|
this.updateShipPresentation();
|
||||||
|
this.updateNetworkPanel();
|
||||||
this.renderer.render(this.scene, this.camera);
|
this.renderer.render(this.scene, this.camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private recordDeltaStats(delta: WorldDelta, rawBytes: number) {
|
||||||
|
const changedEntities = delta.ships.length + delta.stations.length + delta.nodes.length + delta.factions.length;
|
||||||
|
this.networkStats.deltasReceived += 1;
|
||||||
|
this.networkStats.deltaBytes += rawBytes;
|
||||||
|
this.networkStats.lastDeltaBytes = rawBytes;
|
||||||
|
this.networkStats.lastEntityChanges = changedEntities;
|
||||||
|
this.networkStats.eventsReceived += delta.events.length;
|
||||||
|
this.networkStats.lastDeltaAtMs = performance.now();
|
||||||
|
this.networkStats.throughputSamples.push({
|
||||||
|
atMs: performance.now(),
|
||||||
|
bytes: rawBytes,
|
||||||
|
});
|
||||||
|
const cutoff = performance.now() - 4000;
|
||||||
|
this.networkStats.throughputSamples = this.networkStats.throughputSamples.filter((sample) => sample.atMs >= cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateNetworkPanel() {
|
||||||
|
const now = performance.now();
|
||||||
|
const uptimeSeconds = this.networkStats.streamOpenedAtMs
|
||||||
|
? (now - this.networkStats.streamOpenedAtMs) / 1000
|
||||||
|
: 0;
|
||||||
|
const recentBytes = this.networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0);
|
||||||
|
const recentWindowSeconds = this.networkStats.throughputSamples.length > 1
|
||||||
|
? Math.max((now - this.networkStats.throughputSamples[0].atMs) / 1000, 1)
|
||||||
|
: 1;
|
||||||
|
const kbPerSecond = recentBytes / 1024 / recentWindowSeconds;
|
||||||
|
const averageDeltaBytes = this.networkStats.deltasReceived > 0
|
||||||
|
? this.networkStats.deltaBytes / this.networkStats.deltasReceived
|
||||||
|
: 0;
|
||||||
|
const secondsSinceLastDelta = this.networkStats.lastDeltaAtMs
|
||||||
|
? ((now - this.networkStats.lastDeltaAtMs) / 1000).toFixed(1)
|
||||||
|
: "n/a";
|
||||||
|
|
||||||
|
this.networkPanelEl.textContent = this.buildNetworkPanelText({
|
||||||
|
uptimeSeconds,
|
||||||
|
kbPerSecond,
|
||||||
|
averageDeltaBytes,
|
||||||
|
secondsSinceLastDelta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private updateShipPresentation() {
|
private updateShipPresentation() {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
for (const visual of this.shipVisuals.values()) {
|
for (const visual of this.shipVisuals.values()) {
|
||||||
@@ -584,6 +671,36 @@ export class GameViewer {
|
|||||||
return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`;
|
return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatBytes(bytes: number) {
|
||||||
|
if (bytes >= 1024 * 1024) {
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
if (bytes >= 1024) {
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
return `${Math.round(bytes)} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildNetworkPanelText(values: {
|
||||||
|
uptimeSeconds: number;
|
||||||
|
kbPerSecond: number;
|
||||||
|
averageDeltaBytes: number;
|
||||||
|
secondsSinceLastDelta: string;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
`snapshot: ${this.formatBytes(this.networkStats.snapshotBytes)}`,
|
||||||
|
`stream: ${this.networkStats.streamConnected ? "live" : "offline"}`,
|
||||||
|
`deltas: ${this.networkStats.deltasReceived}`,
|
||||||
|
`events: ${this.networkStats.eventsReceived}`,
|
||||||
|
`avg delta: ${this.formatBytes(values.averageDeltaBytes)}`,
|
||||||
|
`last delta: ${this.formatBytes(this.networkStats.lastDeltaBytes)}`,
|
||||||
|
`recent rate: ${values.kbPerSecond.toFixed(1)} KB/s`,
|
||||||
|
`changed: ${this.networkStats.lastEntityChanges}`,
|
||||||
|
`uptime: ${values.uptimeSeconds.toFixed(1)}s`,
|
||||||
|
`last packet: ${values.secondsSinceLastDelta}s`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
private toThreeVector(vector: Vector3Dto) {
|
private toThreeVector(vector: Vector3Dto) {
|
||||||
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export async function fetchWorldSnapshot(signal?: AbortSignal) {
|
|||||||
export function openWorldStream(
|
export function openWorldStream(
|
||||||
afterSequence: number,
|
afterSequence: number,
|
||||||
handlers: {
|
handlers: {
|
||||||
onDelta: (delta: WorldDelta) => void;
|
onDelta: (delta: WorldDelta, rawBytes: number) => void;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
},
|
},
|
||||||
@@ -21,7 +21,10 @@ export function openWorldStream(
|
|||||||
stream.addEventListener("error", () => handlers.onError?.());
|
stream.addEventListener("error", () => handlers.onError?.());
|
||||||
stream.addEventListener("world-delta", (event) => {
|
stream.addEventListener("world-delta", (event) => {
|
||||||
const message = event as MessageEvent<string>;
|
const message = event as MessageEvent<string>;
|
||||||
handlers.onDelta(JSON.parse(message.data) as WorldDelta);
|
handlers.onDelta(
|
||||||
|
JSON.parse(message.data) as WorldDelta,
|
||||||
|
new Blob([message.data]).size,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ canvas {
|
|||||||
|
|
||||||
.topbar,
|
.topbar,
|
||||||
.details-panel,
|
.details-panel,
|
||||||
|
.network-panel,
|
||||||
.faction-strip {
|
.faction-strip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
@@ -118,6 +119,16 @@ canvas {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.network-panel {
|
||||||
|
top: 110px;
|
||||||
|
left: 20px;
|
||||||
|
width: min(360px, calc(100vw - 40px));
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 18px;
|
||||||
|
color: var(--text);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.details-panel h2 {
|
.details-panel h2 {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
@@ -125,6 +136,23 @@ canvas {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.network-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-body {
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
@@ -214,6 +242,13 @@ canvas {
|
|||||||
max-height: 38vh;
|
max-height: 38vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.network-panel {
|
||||||
|
top: 110px;
|
||||||
|
right: 20px;
|
||||||
|
left: 20px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.faction-strip {
|
.faction-strip {
|
||||||
left: 20px;
|
left: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
|
|||||||
Reference in New Issue
Block a user