feat: add production mode

There are two ways to enable this production mode:

- either with the new "mode" option:

```js
instrument(io, {
  mode: "production" // defaults to "development"
});
```

- or with the NODE_ENV environment variable:

```
NODE_ENV=production node index.js
```

In production mode, the server won't send all details about the socket
instances and the rooms, thus reducing the memory footprint of the
instrumentation.

Related:

- https://github.com/socketio/socket.io-admin-ui/issues/22
- https://github.com/socketio/socket.io-admin-ui/issues/23
This commit is contained in:
Damien Arrachequesne 2022-06-22 00:59:09 +02:00
parent 481ef22b3a
commit e0d91cadb1
No known key found for this signature in database
GPG Key ID: 544D14663E7F7CF0
21 changed files with 693 additions and 192 deletions

View File

@ -2,6 +2,8 @@ import { Namespace, RemoteSocket, Server, Socket } from "socket.io";
import {
ClientEvents,
Feature,
NamespaceDetails,
NamespaceEvent,
SerializedSocket,
ServerEvents,
} from "./typed-events";
@ -46,6 +48,10 @@ interface InstrumentOptions {
* The store
*/
store: Store;
/**
* Whether to send all events or only aggregated events to the UI, for performance purposes.
*/
mode: "development" | "production";
}
const initAuthenticationMiddleware = (
@ -120,16 +126,26 @@ const initStatsEmitter = (
pid: process.pid,
};
const io = adminNamespace.server;
const emitStats = () => {
debug("emit stats");
// @ts-ignore private reference
const clientsCount = adminNamespace.server.engine.clientsCount;
const namespaces: NamespaceDetails[] = [];
io._nsps.forEach((namespace) => {
namespaces.push({
name: namespace.name,
socketsCount: namespace.sockets.size,
});
});
adminNamespace.emit(
"server_stats",
Object.assign({}, baseStats, {
uptime: process.uptime(),
clientsCount,
clientsCount: io.engine.clientsCount,
pollingClientsCount: io._pollingClientsCount,
aggregatedEvents: io._eventBuffer.getValuesAndClear(),
namespaces,
})
);
};
@ -295,7 +311,7 @@ const registerFeatureHandlers = (
}
};
const registerListeners = (
const registerVerboseListeners = (
adminNamespace: Namespace<{}, ServerEvents>,
nsp: Namespace
) => {
@ -407,6 +423,81 @@ const serializeData = (data: any) => {
return obj;
};
declare module "socket.io" {
interface Server {
_eventBuffer: EventBuffer;
_pollingClientsCount: number;
}
}
class EventBuffer {
private buffer: Map<string, NamespaceEvent> = new Map();
public push(type: string, subType?: string, count = 1) {
const timestamp = new Date();
timestamp.setMilliseconds(0);
const key = `${timestamp.getTime()};${type};${subType}`;
if (this.buffer.has(key)) {
this.buffer.get(key)!.count += count;
} else {
this.buffer.set(key, {
timestamp: timestamp.getTime(),
type,
subType,
count,
});
}
}
public getValuesAndClear() {
const values = [...this.buffer.values()];
this.buffer.clear();
return values;
}
}
const registerEngineListeners = (io: Server) => {
io._eventBuffer = new EventBuffer();
io._pollingClientsCount = 0;
io.engine.on("connection", (rawSocket: any) => {
io._eventBuffer.push("rawConnection");
if (rawSocket.transport.name === "polling") {
io._pollingClientsCount++;
const decr = () => {
io._pollingClientsCount--;
};
rawSocket.once("upgrade", () => {
rawSocket.removeListener("close", decr);
decr();
});
rawSocket.once("close", decr);
}
rawSocket.on("packetCreate", ({ data }: { data: string | Buffer }) => {
if (data) {
io._eventBuffer.push("packetsOut", undefined);
io._eventBuffer.push("bytesOut", undefined, Buffer.byteLength(data));
}
});
rawSocket.on("packet", ({ data }: { data: string | Buffer }) => {
if (data) {
io._eventBuffer.push("packetsIn", undefined);
io._eventBuffer.push("bytesIn", undefined, Buffer.byteLength(data));
}
});
rawSocket.on("close", (reason: string) => {
io._eventBuffer.push("rawDisconnection", reason);
});
});
};
export function instrument(io: Server, opts: Partial<InstrumentOptions>) {
const options: InstrumentOptions = Object.assign(
{
@ -415,6 +506,7 @@ export function instrument(io: Server, opts: Partial<InstrumentOptions>) {
readonly: false,
serverId: undefined,
store: new InMemoryStore(),
mode: process.env.NODE_ENV || "development",
},
opts
);
@ -428,10 +520,13 @@ export function instrument(io: Server, opts: Partial<InstrumentOptions>) {
initAuthenticationMiddleware(adminNamespace, options);
const supportedFeatures = options.readonly ? [] : detectSupportedFeatures(io);
supportedFeatures.push(Feature.AGGREGATED_EVENTS);
const isDevelopmentMode = options.mode === "development";
if (isDevelopmentMode) {
supportedFeatures.push(Feature.ALL_EVENTS);
}
debug("supported features: %j", supportedFeatures);
initStatsEmitter(adminNamespace, options.serverId);
adminNamespace.on("connection", async (socket) => {
registerFeatureHandlers(io, socket, supportedFeatures);
@ -439,11 +534,22 @@ export function instrument(io: Server, opts: Partial<InstrumentOptions>) {
supportedFeatures,
});
socket.emit("all_sockets", await fetchAllSockets(io));
if (isDevelopmentMode) {
socket.emit("all_sockets", await fetchAllSockets(io));
}
});
io._nsps.forEach((nsp) => registerListeners(adminNamespace, nsp));
io.on("new_namespace", (nsp) => registerListeners(adminNamespace, nsp));
registerEngineListeners(io);
if (isDevelopmentMode) {
const registerNamespaceListeners = (nsp: Namespace) => {
registerVerboseListeners(adminNamespace, nsp);
};
io._nsps.forEach(registerNamespaceListeners);
io.on("new_namespace", registerNamespaceListeners);
}
initStatsEmitter(adminNamespace, options.serverId);
}
export { InMemoryStore, RedisStore } from "./stores";

View File

@ -7,18 +7,35 @@ export enum Feature {
MJOIN = "MJOIN",
MLEAVE = "MLEAVE",
MDISCONNECT = "MDISCONNECT",
AGGREGATED_EVENTS = "AGGREGATED_EVENTS",
ALL_EVENTS = "ALL_EVENTS",
}
interface Config {
supportedFeatures: Feature[];
}
export type NamespaceEvent = {
timestamp: number;
type: string;
subType?: string;
count: number;
};
export type NamespaceDetails = {
name: string;
socketsCount: number;
};
interface ServerStats {
serverId: string;
hostname: string;
pid: number;
uptime: number;
clientsCount: number;
pollingClientsCount: number;
namespaces: NamespaceDetails[];
}
export interface SerializedSocket {

View File

@ -3,7 +3,7 @@ import { Server } from "socket.io";
import { Server as ServerV3 } from "socket.io-v3";
import { io as ioc } from "socket.io-client";
import { AddressInfo } from "net";
import { InMemoryStore, instrument, RedisStore } from "..";
import { InMemoryStore, instrument, RedisStore } from "../lib";
import expect = require("expect.js");
import { createClient } from "redis";
@ -189,6 +189,8 @@ describe("Socket.IO Admin (server instrumentation)", () => {
"MJOIN",
"MLEAVE",
"MDISCONNECT",
"AGGREGATED_EVENTS",
"ALL_EVENTS",
]);
} else {
expect(config.supportedFeatures).to.eql([
@ -196,6 +198,8 @@ describe("Socket.IO Admin (server instrumentation)", () => {
"JOIN",
"LEAVE",
"DISCONNECT",
"AGGREGATED_EVENTS",
"ALL_EVENTS",
]);
}
adminSocket.disconnect();
@ -212,7 +216,26 @@ describe("Socket.IO Admin (server instrumentation)", () => {
const adminSocket = ioc(`http://localhost:${port}/admin`);
adminSocket.on("config", (config: any) => {
expect(config.supportedFeatures).to.eql([]);
expect(config.supportedFeatures).to.eql([
"AGGREGATED_EVENTS",
"ALL_EVENTS",
]);
adminSocket.disconnect();
done();
});
});
it("returns an empty list of supported features when in production mode", (done) => {
instrument(io, {
auth: false,
readonly: true,
mode: "production",
});
const adminSocket = ioc(`http://localhost:${port}/admin`);
adminSocket.on("config", (config: any) => {
expect(config.supportedFeatures).to.eql(["AGGREGATED_EVENTS"]);
adminSocket.disconnect();
done();
});

153
ui/package-lock.json generated
View File

@ -8,9 +8,12 @@
"name": "ui",
"version": "0.3.0",
"dependencies": {
"chartjs-adapter-date-fns": "^2.0.0",
"core-js": "^3.6.5",
"date-fns": "^2.28.0",
"socket.io-msgpack-parser": "^3.0.1",
"vue": "^2.6.11",
"vue-chartjs": "^4.1.1",
"vue-i18n": "^8.22.3",
"vue-router": "^3.2.0",
"vuetify": "^2.4.0",
@ -24,7 +27,6 @@
"@vue/cli-service": "~4.5.0",
"@vue/eslint-config-prettier": "^6.0.0",
"babel-eslint": "^10.1.0",
"chart.js": "^2.9.4",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-vue": "^6.2.2",
@ -34,7 +36,6 @@
"sass": "^1.32.0",
"sass-loader": "^10.0.0",
"socket.io-client": "^4.5.0",
"vue-chartjs": "^3.5.1",
"vue-cli-plugin-i18n": "~2.0.3",
"vue-cli-plugin-vuetify": "~2.3.1",
"vue-template-compiler": "^2.6.11",
@ -1507,15 +1508,6 @@
"@types/node": "*"
}
},
"node_modules/@types/chart.js": {
"version": "2.9.32",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.32.tgz",
"integrity": "sha512-d45JiRQwEOlZiKwukjqmqpbqbYzUX2yrXdH9qVn6kXpPDsTYCo6YbfFOlnUaJ8S/DhJwbBJiLsMjKpW5oP8B2A==",
"dev": true,
"dependencies": {
"moment": "^2.10.2"
}
},
"node_modules/@types/connect": {
"version": "3.4.34",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz",
@ -3784,32 +3776,17 @@
"dev": true
},
"node_modules/chart.js": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
"dev": true,
"dependencies": {
"chartjs-color": "^2.1.0",
"moment": "^2.10.2"
}
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.8.0.tgz",
"integrity": "sha512-cr8xhrXjLIXVLOBZPkBZVF6NDeiVIrPLHcMhnON7UufudL+CNeRrD+wpYanswlm8NpudMdrt3CHoLMQMxJhHRg==",
"peer": true
},
"node_modules/chartjs-color": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
"dev": true,
"dependencies": {
"chartjs-color-string": "^0.6.0",
"color-convert": "^1.9.3"
}
},
"node_modules/chartjs-color-string": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
"dev": true,
"dependencies": {
"color-name": "^1.0.0"
"node_modules/chartjs-adapter-date-fns": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz",
"integrity": "sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw==",
"peerDependencies": {
"chart.js": "^3.0.0"
}
},
"node_modules/check-types": {
@ -5198,6 +5175,18 @@
"node": ">=0.10"
}
},
"node_modules/date-fns": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz",
"integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==",
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@ -9923,15 +9912,6 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/moment": {
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==",
"dev": true,
"engines": {
"node": "*"
}
},
"node_modules/move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@ -14991,19 +14971,12 @@
"integrity": "sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg=="
},
"node_modules/vue-chartjs": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-3.5.1.tgz",
"integrity": "sha512-foocQbJ7FtveICxb4EV5QuVpo6d8CmZFmAopBppDIGKY+esJV8IJgwmEW0RexQhxqXaL/E1xNURsgFFYyKzS/g==",
"dev": true,
"dependencies": {
"@types/chart.js": "^2.7.55"
},
"engines": {
"node": ">=6.9.0",
"npm": ">= 3.0.0"
},
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-4.1.1.tgz",
"integrity": "sha512-rKIQ3jPrjhwxjKdNJppnYxRuBSrx4QeM3nNHsfIxEqjX6QS4Jq6e6vnZBxh2MDpURDC2uvuI2N0eIt1cWXbBVA==",
"peerDependencies": {
"chart.js": ">= 2.5"
"chart.js": "^3.7.0",
"vue": "^3.0.0-0 || ^2.6.0"
}
},
"node_modules/vue-cli-plugin-i18n": {
@ -17948,15 +17921,6 @@
"@types/node": "*"
}
},
"@types/chart.js": {
"version": "2.9.32",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.32.tgz",
"integrity": "sha512-d45JiRQwEOlZiKwukjqmqpbqbYzUX2yrXdH9qVn6kXpPDsTYCo6YbfFOlnUaJ8S/DhJwbBJiLsMjKpW5oP8B2A==",
"dev": true,
"requires": {
"moment": "^2.10.2"
}
},
"@types/connect": {
"version": "3.4.34",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz",
@ -19825,33 +19789,16 @@
"dev": true
},
"chart.js": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
"dev": true,
"requires": {
"chartjs-color": "^2.1.0",
"moment": "^2.10.2"
}
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.8.0.tgz",
"integrity": "sha512-cr8xhrXjLIXVLOBZPkBZVF6NDeiVIrPLHcMhnON7UufudL+CNeRrD+wpYanswlm8NpudMdrt3CHoLMQMxJhHRg==",
"peer": true
},
"chartjs-color": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
"dev": true,
"requires": {
"chartjs-color-string": "^0.6.0",
"color-convert": "^1.9.3"
}
},
"chartjs-color-string": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
"dev": true,
"requires": {
"color-name": "^1.0.0"
}
"chartjs-adapter-date-fns": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz",
"integrity": "sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw==",
"requires": {}
},
"check-types": {
"version": "8.0.3",
@ -20971,6 +20918,11 @@
"assert-plus": "^1.0.0"
}
},
"date-fns": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz",
"integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw=="
},
"de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@ -24695,12 +24647,6 @@
"minimist": "^1.2.5"
}
},
"moment": {
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==",
"dev": true
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@ -28878,13 +28824,10 @@
"integrity": "sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg=="
},
"vue-chartjs": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-3.5.1.tgz",
"integrity": "sha512-foocQbJ7FtveICxb4EV5QuVpo6d8CmZFmAopBppDIGKY+esJV8IJgwmEW0RexQhxqXaL/E1xNURsgFFYyKzS/g==",
"dev": true,
"requires": {
"@types/chart.js": "^2.7.55"
}
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-4.1.1.tgz",
"integrity": "sha512-rKIQ3jPrjhwxjKdNJppnYxRuBSrx4QeM3nNHsfIxEqjX6QS4Jq6e6vnZBxh2MDpURDC2uvuI2N0eIt1cWXbBVA==",
"requires": {}
},
"vue-cli-plugin-i18n": {
"version": "2.0.3",

View File

@ -9,9 +9,12 @@
"i18n:report": "vue-cli-service i18n:report --src \"./src/**/*.?(js|vue)\" --locales \"./src/locales/**/*.json\""
},
"dependencies": {
"chartjs-adapter-date-fns": "^2.0.0",
"core-js": "^3.6.5",
"date-fns": "^2.28.0",
"socket.io-msgpack-parser": "^3.0.1",
"vue": "^2.6.11",
"vue-chartjs": "^4.1.1",
"vue-i18n": "^8.22.3",
"vue-router": "^3.2.0",
"vuetify": "^2.4.0",
@ -25,7 +28,6 @@
"@vue/cli-service": "~4.5.0",
"@vue/eslint-config-prettier": "^6.0.0",
"babel-eslint": "^10.1.0",
"chart.js": "^2.9.4",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-vue": "^6.2.2",
@ -35,7 +37,6 @@
"sass": "^1.32.0",
"sass-loader": "^10.0.0",
"socket.io-client": "^4.5.0",
"vue-chartjs": "^3.5.1",
"vue-cli-plugin-i18n": "~2.0.3",
"vue-cli-plugin-vuetify": "~2.3.1",
"vue-template-compiler": "^2.6.11",

View File

@ -154,6 +154,7 @@ export default {
});
socket.on("server_stats", (serverStats) => {
this.$store.commit("servers/onServerStats", serverStats);
this.$store.commit("main/onServerStats", serverStats);
});
socket.on("all_sockets", (sockets) => {
this.$store.commit("main/onAllSockets", sockets);

View File

@ -0,0 +1,113 @@
<template>
<v-card>
<v-card-title class="text-center">
{{ $t("dashboard.bytesHistogram.title") }}
</v-card-title>
<v-card-text>
<v-row>
<Bar
:chart-data="chartData"
:chart-options="chartOptions"
style="width: 100%"
:height="chartHeight"
/>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
import colors from "vuetify/lib/util/colors";
import { mapState } from "vuex";
import { Bar } from "vue-chartjs/legacy";
import { subMinutes } from "date-fns";
function mapAggregatedEvent(event) {
return {
x: event.timestamp,
y: event.count,
};
}
export default {
name: "BytesHistogram",
components: {
Bar,
},
data() {
return {
chartHeight: 120,
chartOptions: {
parsing: false,
scales: {
x: {
type: "time",
time: {
stepSize: 1,
unit: "minute",
},
},
y: {
type: "linear",
beginAtZero: true,
suggestedMax: 1000,
ticks: {
precision: 0,
},
},
},
},
};
},
computed: {
...mapState("main", ["aggregatedEvents"]),
bytesIn() {
return this.aggregatedEvents
.filter((event) => event.type === "bytesIn")
.map(mapAggregatedEvent);
},
bytesOut() {
return this.aggregatedEvents
.filter((event) => event.type === "bytesOut")
.map(mapAggregatedEvent);
},
chartData() {
return {
datasets: [
{
label: this.$i18n.t("dashboard.bytesHistogram.bytesIn"),
backgroundColor: colors.green.base,
data: this.bytesIn,
},
{
label: this.$i18n.t("dashboard.bytesHistogram.bytesOut"),
backgroundColor: colors.red.base,
data: this.bytesOut,
},
],
};
},
},
created() {
this.updateChartBounds();
this.interval = setInterval(this.updateChartBounds, 10000);
},
beforeDestroy() {
clearInterval(this.interval);
},
methods: {
updateChartBounds() {
const now = new Date();
this.chartOptions.scales.x.min = subMinutes(now, 10);
this.chartOptions.scales.x.max = now;
},
},
};
</script>

View File

@ -10,7 +10,11 @@
<v-card-text>
<v-row>
<Doughnut :chart-data="data" class="chart" />
<Doughnut
:chart-data="data"
class="chart"
:chart-options="chartOptions"
/>
<v-simple-table class="grow align-self-center">
<template v-slot:default>
@ -23,14 +27,11 @@
<td><Transport :transport="transport" /></td>
<td>
<div>
<h2>{{ transportRepartition[transport] || 0 }}</h2>
<h2>{{ transportRepartition[transport] }}</h2>
</div>
<div>
{{
percentage(
transportRepartition[transport] || 0,
clients.length
)
percentage(transportRepartition[transport], clientsCount)
}}
%
</div>
@ -45,11 +46,12 @@
</template>
<script>
import Doughnut from "./Doughnut";
import { mapState } from "vuex";
import { Doughnut } from "vue-chartjs/legacy";
import { mapState, mapGetters } from "vuex";
import colors from "vuetify/lib/util/colors";
import Transport from "../Transport";
import { percentage } from "../../util";
import { sumBy } from "lodash-es";
export default {
name: "ClientsOverview",
@ -62,6 +64,13 @@ export default {
data() {
return {
transports: ["websocket", "polling"],
chartOptions: {
plugins: {
legend: {
display: false,
},
},
},
};
},
@ -69,18 +78,38 @@ export default {
...mapState({
clients: (state) => state.main.clients,
darkTheme: (state) => state.config.darkTheme,
servers: (state) => state.servers.servers,
}),
...mapGetters("config", ["hasAggregatedValues"]),
clientsCount() {
if (this.hasAggregatedValues) {
return sumBy(this.servers, "clientsCount");
} else {
return this.clients.length;
}
},
transportRepartition() {
if (this.hasAggregatedValues) {
const pollingClientsCount = sumBy(this.servers, "pollingClientsCount");
return {
polling: pollingClientsCount,
websocket: this.clientsCount - pollingClientsCount,
};
}
return this.clients
.map((client) => {
return client.sockets[0];
})
.filter((socket) => !!socket)
.reduce((acc, socket) => {
acc[socket.transport] = acc[socket.transport] || 0;
acc[socket.transport]++;
return acc;
}, {});
.reduce(
(acc, socket) => {
acc[socket.transport]++;
return acc;
},
{ websocket: 0, polling: 0 }
);
},
data() {
return {

View File

@ -0,0 +1,113 @@
<template>
<v-card>
<v-card-title class="text-center">
{{ $t("dashboard.connectionsHistogram.title") }}
</v-card-title>
<v-card-text>
<v-row>
<Bar
:chart-data="chartData"
:chart-options="chartOptions"
style="width: 100%"
:height="chartHeight"
/>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
import colors from "vuetify/lib/util/colors";
import { mapState } from "vuex";
import { Bar } from "vue-chartjs/legacy";
import { subMinutes } from "date-fns";
function mapAggregatedEvent(event) {
return {
x: event.timestamp,
y: event.count,
};
}
export default {
name: "ConnectionsHistogram",
components: {
Bar,
},
data() {
return {
chartHeight: 120,
chartOptions: {
parsing: false,
scales: {
x: {
type: "time",
time: {
stepSize: 1,
unit: "minute",
},
},
y: {
type: "linear",
beginAtZero: true,
suggestedMax: 10,
ticks: {
precision: 0,
},
},
},
},
};
},
computed: {
...mapState("main", ["aggregatedEvents"]),
connectionEvents() {
return this.aggregatedEvents
.filter((event) => event.type === "rawConnection")
.map(mapAggregatedEvent);
},
disconnectionEvents() {
return this.aggregatedEvents
.filter((event) => event.type === "rawDisconnection")
.map(mapAggregatedEvent);
},
chartData() {
return {
datasets: [
{
label: this.$i18n.t("events.type.connection"),
backgroundColor: colors.green.base,
data: this.connectionEvents,
},
{
label: this.$i18n.t("events.type.disconnection"),
backgroundColor: colors.red.base,
data: this.disconnectionEvents,
},
],
};
},
},
created() {
this.updateChartBounds();
this.interval = setInterval(this.updateChartBounds, 10000);
},
beforeDestroy() {
clearInterval(this.interval);
},
methods: {
updateChartBounds() {
const now = new Date();
this.chartOptions.scales.x.min = subMinutes(now, 10);
this.chartOptions.scales.x.max = now;
},
},
};
</script>

View File

@ -1,15 +0,0 @@
<script>
import { Doughnut, mixins } from "vue-chartjs";
const { reactiveProp } = mixins;
export default {
extends: Doughnut,
mixins: [reactiveProp],
props: ["options"],
mounted() {
this.renderChart(this.chartData, {
legend: false,
});
},
};
</script>

View File

@ -21,7 +21,7 @@
<td class="key-column">
<code>{{ namespace.name }}</code>
</td>
<td>{{ namespace.sockets.length }}</td>
<td>{{ namespace.socketsCount }}</td>
</tr>
</tbody>
</template>
@ -30,7 +30,7 @@
</template>
<script>
import { mapState } from "vuex";
import { mapState, mapGetters } from "vuex";
import { sortBy } from "lodash-es";
export default {
@ -38,8 +38,23 @@ export default {
computed: {
...mapState({
namespaces: (state) => sortBy(state.main.namespaces, "name"),
plainNamespaces: (state) =>
sortBy(state.main.namespaces, "name").map(({ name, sockets }) => {
return {
name,
socketsCount: sockets.length,
};
}),
}),
...mapGetters("config", ["hasAggregatedValues"]),
...mapGetters("servers", {
liteNamespaces: "namespaces",
}),
namespaces() {
return this.hasAggregatedValues
? this.liteNamespaces
: this.plainNamespaces;
},
},
};
</script>

View File

@ -10,7 +10,11 @@
<v-card-text>
<v-row>
<Doughnut :chart-data="data" class="chart" />
<Doughnut
:chart-data="data"
:chart-options="chartOptions"
class="chart"
/>
<v-simple-table class="grow align-self-center">
<template v-slot:default>
@ -51,7 +55,7 @@
</template>
<script>
import Doughnut from "./Doughnut";
import { Doughnut } from "vue-chartjs/legacy";
import { mapState } from "vuex";
import colors from "vuetify/lib/util/colors";
import { percentage } from "../../util";
@ -65,6 +69,18 @@ export default {
Doughnut,
},
data() {
return {
chartOptions: {
plugins: {
legend: {
display: false,
},
},
},
};
},
computed: {
...mapState({
healthyServers: (state) =>

View File

@ -38,46 +38,64 @@
import LangSelector from "./LangSelector";
import ThemeSelector from "./ThemeSelector";
import ReadonlyToggle from "./ReadonlyToggle";
import { mapGetters } from "vuex";
export default {
name: "NavigationDrawer",
components: { ReadonlyToggle, ThemeSelector, LangSelector },
computed: {
...mapGetters("config", ["developmentMode"]),
items() {
return [
{
title: this.$t("dashboard.title"),
icon: "mdi-home-outline",
to: { name: "dashboard" },
exact: true,
},
{
title: this.$t("sockets.title"),
icon: "mdi-ray-start-arrow",
to: { name: "sockets" },
},
{
title: this.$t("rooms.title"),
icon: "mdi-tag-outline",
to: { name: "rooms" },
},
{
title: this.$t("clients.title"),
icon: "mdi-account-circle-outline",
to: { name: "clients" },
},
{
title: this.$t("events.title"),
icon: "mdi-calendar-text-outline",
to: { name: "events" },
},
{
title: this.$t("servers.title"),
icon: "mdi-server",
to: { name: "servers" },
},
];
if (this.developmentMode) {
return [
{
title: this.$t("dashboard.title"),
icon: "mdi-home-outline",
to: { name: "dashboard" },
exact: true,
},
{
title: this.$t("sockets.title"),
icon: "mdi-ray-start-arrow",
to: { name: "sockets" },
},
{
title: this.$t("rooms.title"),
icon: "mdi-tag-outline",
to: { name: "rooms" },
},
{
title: this.$t("clients.title"),
icon: "mdi-account-circle-outline",
to: { name: "clients" },
},
{
title: this.$t("events.title"),
icon: "mdi-calendar-text-outline",
to: { name: "events" },
},
{
title: this.$t("servers.title"),
icon: "mdi-server",
to: { name: "servers" },
},
];
} else {
return [
{
title: this.$t("dashboard.title"),
icon: "mdi-home-outline",
to: { name: "dashboard" },
exact: true,
},
{
title: this.$t("servers.title"),
icon: "mdi-server",
to: { name: "servers" },
},
];
}
},
},
};

View File

@ -32,7 +32,15 @@
"msgpack-parser": "MessagePack parser"
},
"dashboard": {
"title": "Dashboard"
"title": "Dashboard",
"connectionsHistogram": {
"title": "Connection and disconnection events"
},
"bytesHistogram": {
"title": "Bytes received and sent",
"bytesIn": "Bytes received",
"bytesOut": "Bytes sent"
}
},
"sockets": {
"title": "Sockets",

View File

@ -29,7 +29,15 @@
"path": "Chemin HTTP"
},
"dashboard": {
"title": "Accueil"
"title": "Accueil",
"connectionsHistogram": {
"title": "Évènements de connexion et de déconnexion"
},
"bytesHistogram": {
"title": "Octets reçus et envoyés",
"bytesIn": "Octets reçus",
"bytesOut": "Octets envoyés"
}
},
"sockets": {
"title": "Connexions",

View File

@ -4,6 +4,7 @@ import router from "./router";
import i18n from "./i18n";
import store from "./store";
import vuetify from "./plugins/vuetify";
import "./plugins/chartjs";
Vue.config.productionTip = false;

22
ui/src/plugins/chartjs.js Normal file
View File

@ -0,0 +1,22 @@
import {
Chart as ChartJS,
DoughnutController,
Tooltip,
Legend,
ArcElement,
BarElement,
TimeScale,
LinearScale,
} from "chart.js";
ChartJS.register(
DoughnutController,
Tooltip,
Legend,
ArcElement,
BarElement,
TimeScale,
LinearScale
);
import "chartjs-adapter-date-fns";

View File

@ -9,6 +9,17 @@ export default {
supportedFeatures: [],
showNavigationDrawer: false,
},
getters: {
developmentMode(state) {
return (
state.supportedFeatures.includes("ALL_EVENTS") ||
!state.supportedFeatures.includes("AGGREGATED_EVENTS")
);
},
hasAggregatedValues: (state) => {
return state.supportedFeatures.includes("AGGREGATED_EVENTS");
},
},
mutations: {
init(state) {
if (isLocalStorageAvailable) {

View File

@ -1,5 +1,7 @@
import { find, merge } from "lodash-es";
import { pushUniq, remove } from "../../util";
import { find, merge, remove as silentlyRemove } from "lodash-es";
import { pushUniq, remove } from "@/util";
const TEN_MINUTES = 10 * 60 * 1000;
const getOrCreateNamespace = (namespaces, name) => {
let namespace = find(namespaces, { name });
@ -76,12 +78,19 @@ const pushEvents = (array, event) => {
}
};
// group events by each 10 seconds
// see: https://www.chartjs.org/docs/latest/general/performance.html#decimation
function roundedTimestamp(timestamp) {
return timestamp - (timestamp % 10_000);
}
export default {
namespaced: true,
state: {
namespaces: [],
clients: [],
selectedNamespace: null,
aggregatedEvents: [],
},
getters: {
findSocketById: (state) => (nsp, id) => {
@ -199,5 +208,31 @@ export default {
args: room,
});
},
onServerStats(state, serverStats) {
if (!serverStats.aggregatedEvents) {
return;
}
for (const aggregatedEvent of serverStats.aggregatedEvents) {
const timestamp = roundedTimestamp(aggregatedEvent.timestamp);
const elem = find(state.aggregatedEvents, {
timestamp,
type: aggregatedEvent.type,
subType: aggregatedEvent.subType,
});
if (elem) {
elem.count += aggregatedEvent.count;
} else {
state.aggregatedEvents.push({
timestamp,
type: aggregatedEvent.type,
subType: aggregatedEvent.subType,
count: aggregatedEvent.count,
});
}
}
silentlyRemove(state.aggregatedEvents, (elem) => {
return elem.timestamp < Date.now() - TEN_MINUTES;
});
},
},
};

View File

@ -8,6 +8,24 @@ export default {
state: {
servers: [],
},
getters: {
namespaces(state) {
const namespaces = {};
for (const server of state.servers) {
if (server.namespaces) {
for (const { name, socketsCount } of server.namespaces) {
namespaces[name] = (namespaces[name] || 0) + socketsCount;
}
}
}
return Object.keys(namespaces).map((name) => {
return {
name,
socketsCount: namespaces[name],
};
});
},
},
mutations: {
onServerStats(state, stats) {
stats.lastPing = Date.now();

View File

@ -4,17 +4,25 @@
<v-container fluid>
<v-row>
<v-col sm="12" md="6" lg="4">
<v-col cols="12" md="6" lg="4">
<ClientsOverview />
</v-col>
<v-col sm="12" md="6" lg="4">
<v-col cols="12" md="6" lg="4">
<ServersOverview />
</v-col>
<v-col sm="12" md="6" lg="4">
<v-col cols="12" md="6" lg="4">
<NamespacesOverview />
</v-col>
<v-col v-if="hasAggregatedValues" cols="12" md="6">
<ConnectionsHistogram />
</v-col>
<v-col v-if="hasAggregatedValues" cols="12" md="6">
<BytesHistogram />
</v-col>
</v-row>
</v-container>
</div>
@ -24,11 +32,20 @@
import ClientsOverview from "../components/Dashboard/ClientsOverview";
import ServersOverview from "../components/Dashboard/ServersOverview";
import NamespacesOverview from "../components/Dashboard/NamespacesOverview";
import ConnectionsHistogram from "../components/Dashboard/ConnectionsHistogram";
import BytesHistogram from "../components/Dashboard/BytesHistogram";
import { mapGetters } from "vuex";
export default {
name: "Dashboard",
components: { NamespacesOverview, ServersOverview, ClientsOverview },
components: {
NamespacesOverview,
ServersOverview,
ClientsOverview,
ConnectionsHistogram,
BytesHistogram,
},
computed: {
breadcrumbItems() {
@ -39,6 +56,7 @@ export default {
},
];
},
...mapGetters("config", ["hasAggregatedValues"]),
},
};
</script>