Adding comments and allowing base-services to be skipped
This commit is contained in:
parent
cf5aec61c0
commit
b99c1edc6c
@ -3,8 +3,219 @@
|
||||
* @author Martin Karkowski
|
||||
* @email m.karkowski@zema.de
|
||||
*
|
||||
* # NopeConnectivityManager
|
||||
* # NoPE - Connectivity Manager
|
||||
*
|
||||
* The NoPE-Dispatcher uses one `ConnectivityManager`. The manager observes the connection and remotly connected dispatchers (and their `ConnectivityManager`). The Manager detects newly connected dispatchers and disconnected dispatchers. Additionally, it sends a StatusMessage (in the form of `INopeStatusInfo`). This status message is interpreted as heartbeat. The `ConnectivityManager` checks those heartbeats with a defined interval. If a specific amount of time is ellapsed, the remote dispatcher is marked as `slow` -> `warning` -> `dead`. After an additional delay in the state `dead` the dispatcher is altough removed.
|
||||
*
|
||||
* ## Master
|
||||
*
|
||||
* Defaultly a `ConnectivityManager` is elected as `master`. The master is defined as the `ConnectivityManager` with the highest `upTime`.
|
||||
*
|
||||
* > Alternativly a master can be forced.
|
||||
*
|
||||
* ## Synchronizing time
|
||||
*
|
||||
* Because we asume, that **NoPE** is running on different computing nodes, we have to be able to synchronize the time between those elements. Therefore the `ConnectivityManager` is able to sync the time (by providing a `timestamp` and an additional `delay` that was needed to get to the call (for instance `ping / 2`))
|
||||
*
|
||||
*
|
||||
*
|
||||
* ```javascript
|
||||
* // First lets install nope using npm
|
||||
* const nope = require("../dist-nodejs/index.nodejs")
|
||||
*
|
||||
* // Create a communicator:
|
||||
* // We will use the event layer (which just runs internally)
|
||||
* const communicator = nope.getLayer("event");
|
||||
*
|
||||
* // Lets create our dispatcher
|
||||
*
|
||||
* // 1. Dispatcher simulates our local system
|
||||
* const localDispatcher = nope.dispatcher.getDispatcher({
|
||||
* communicator,
|
||||
* id: "local"
|
||||
* }, {
|
||||
* singleton: false,
|
||||
* useBaseServices: false
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* > For Jupyter we need an extra async wrapper to wait for initalizing the dispatcher:
|
||||
*
|
||||
* see here for the details in Jupyter: https://n-riesco.github.io/ijavascript/doc/async.ipynb.html
|
||||
*
|
||||
*
|
||||
* ```javascript
|
||||
* $$.async();
|
||||
* // Lets wait for our element to be ready.
|
||||
* localDispatcher.ready.waitFor().then($$.done);
|
||||
* ```
|
||||
*
|
||||
* Now we want to listen to newly connected dispatchers. For this purpose, we create an observer, which will listen to changes.
|
||||
*
|
||||
*
|
||||
* ```javascript
|
||||
* // Subscribe to changes
|
||||
* const observer = localDispatcher.connectivityManager.dispatchers.onChange.subscribe(data => {
|
||||
* // Log the changes
|
||||
* console.log((new Date()).toISOString(),"onChange - listener");
|
||||
* console.log("\tadded =", data.added);
|
||||
* console.log("\tremoved =", data.removed);
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Additionally we want to show the currently connected dispatchers. In this data the own dispatcher will **allways** be included:
|
||||
*
|
||||
*
|
||||
* ```javascript
|
||||
* // Show our connected Dispatchers
|
||||
* let connectedDispatchers = localDispatcher.connectivityManager.dispatchers.data.getContent();
|
||||
* let localDispatcherIncluded = connectedDispatchers.includes(localDispatcher.id);
|
||||
*
|
||||
* // Now lets log our results.
|
||||
* console.log("connectedDispatchers =", connectedDispatchers);
|
||||
* console.log("localDispatcherIncluded =", localDispatcherIncluded);
|
||||
* ```
|
||||
*
|
||||
* >```
|
||||
* > connectedDispatchers = [ 'local' ]
|
||||
* > localDispatcherIncluded = true
|
||||
* >```
|
||||
*
|
||||
* Now that we have implemented our listeners and have seen the connected dispatchers (which is only the `"local"`-dispatchre), We will add an additional dispatcher. This should result in calling our `onChange`-listener. Additionally, we wait until our `remoteDispatcher` is initalized
|
||||
*
|
||||
*
|
||||
* ```javascript
|
||||
* // 2. Dispatcher simulates our remote system
|
||||
* const remoteDispatcher = nope.dispatcher.getDispatcher({
|
||||
* communicator,
|
||||
* id: "remote"
|
||||
* }, {
|
||||
* singleton: false,
|
||||
* useBaseServices: false
|
||||
* });
|
||||
*
|
||||
* ```
|
||||
*
|
||||
* >```
|
||||
* > 2022-01-20T11:39:55.766Z onChange - listener
|
||||
* > added = [ 'remote' ]
|
||||
* > removed = []
|
||||
* >```
|
||||
*
|
||||
* Now we want to see, which system is the current master. This should be our `local`.
|
||||
*
|
||||
*
|
||||
* ```javascript
|
||||
* // We expect to be the master, because the localDispatcher has been created first.
|
||||
* console.log("master =", localDispatcher.connectivityManager.master.id);
|
||||
* ```
|
||||
*
|
||||
* > `master = local`
|
||||
*
|
||||
*
|
||||
* We can now force the remote dispatcher to be our master, by setting the master. (For this purpose we can later use a base service ==> then we just have to call the service)
|
||||
*
|
||||
*
|
||||
* ```javascript
|
||||
* $$.async();
|
||||
*
|
||||
* remoteDispatcher.connectivityManager.isMaster = true;
|
||||
* localDispatcher.connectivityManager.isMaster = false;
|
||||
*
|
||||
* // Our messaging is async ==> we wait an amount of time
|
||||
* setTimeout(() => $$.done(),1000);
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* ```javascript
|
||||
* // We expect the master to be the remote.
|
||||
* console.log("master =", localDispatcher.connectivityManager.master.id);
|
||||
* console.log("master-info =", localDispatcher.connectivityManager.master);
|
||||
* ```
|
||||
*
|
||||
* >```
|
||||
* > master = remote
|
||||
* > master-info = {
|
||||
* > id: 'remote',
|
||||
* > env: 'javascript',
|
||||
* > version: '1.0.0',
|
||||
* > isMaster: true,
|
||||
* > host: {
|
||||
* > cores: 8,
|
||||
* > cpu: {
|
||||
* > model: 'Intel(R) Core(TM) i7-8565U CPU',
|
||||
* > speed: 1992,
|
||||
* > usage: 0.0038778477944740875
|
||||
* > },
|
||||
* > os: 'win32',
|
||||
* > ram: { usedPerc: 0.362681220626356, free: 20676, total: 32442 },
|
||||
* > name: 'nz-078'
|
||||
* > },
|
||||
* > pid: 18068,
|
||||
* > timestamp: 1642678798813,
|
||||
* > upTime: 3049,
|
||||
* > status: 0
|
||||
* > }
|
||||
* >```
|
||||
*
|
||||
*
|
||||
* Now lets see what happens if we adapt the heartbeat intervall of our *local* instance. We want to receive every 50 ms a heartbeat:
|
||||
*
|
||||
*
|
||||
* ```javascript
|
||||
* $$.async()
|
||||
*
|
||||
* const renderStatus = () => {
|
||||
* console.log((new Date()).toISOString(),"master-info =", localDispatcher.connectivityManager.master.status)
|
||||
* }
|
||||
*
|
||||
* setTimeout(renderStatus, 50);
|
||||
* setTimeout(renderStatus, 750);
|
||||
* setTimeout(renderStatus, 1500);
|
||||
* setTimeout(renderStatus, 2500);
|
||||
*
|
||||
*
|
||||
* localDispatcher.connectivityManager.setTimings({
|
||||
* // our system will send every 50 ms an heartbeat.
|
||||
* sendAliveInterval: 250,
|
||||
* // we will check that after
|
||||
* checkInterval: 125,
|
||||
* // will mark dispatchers as slow after not receiving heartbeats for 50ms
|
||||
* slow: 500,
|
||||
* // we will mark dispatchers with a warning flag after 50 ms
|
||||
* warn: 1000,
|
||||
* // we mark it as dead after 0.5 s
|
||||
* dead: 2000,
|
||||
* // We will remove the dispatcher after 1 s
|
||||
* remove: 3000,
|
||||
* });
|
||||
*
|
||||
* remoteDispatcher.connectivityManager.setTimings({
|
||||
* // our system will send every 50 ms an heartbeat.
|
||||
* sendAliveInterval: 5000,
|
||||
* });
|
||||
*
|
||||
*
|
||||
*
|
||||
* // We reset the timeouts.
|
||||
* setTimeout(() => localDispatcher.connectivityManager.setTimings({}), 3000);
|
||||
* setTimeout(() => remoteDispatcher.connectivityManager.setTimings({}), 3000);
|
||||
* setTimeout(() => $$.done(), 5000);
|
||||
*
|
||||
* ```
|
||||
*
|
||||
* >```
|
||||
* > 2022-01-20T11:40:01.089Z master-info = 0
|
||||
* > 2022-01-20T11:40:01.789Z master-info = 1
|
||||
* > 2022-01-20T11:40:02.536Z master-info = 2
|
||||
* > 2022-01-20T11:40:03.543Z master-info = 3
|
||||
* > 2022-01-20T11:40:03.977Z onChange - listener
|
||||
* > added = []
|
||||
* > removed = [ 'remote' ]
|
||||
* > 2022-01-20T11:40:04.547Z onChange - listener
|
||||
* > added = [ 'remote' ]
|
||||
* > removed = []
|
||||
* >```
|
||||
*
|
||||
*/
|
||||
|
||||
|
@ -19,24 +19,39 @@ import { NopeDispatcher } from "./nopeDispatcher";
|
||||
|
||||
/**
|
||||
* Function to extract a Singleton Dispatcher
|
||||
* @param options The provided options for the Dispatcher
|
||||
* @param dispatcherOptions The provided options for the Dispatcher
|
||||
*
|
||||
* ```typescript
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
export function getDispatcher(
|
||||
options: INopeDispatcherOptions,
|
||||
constructorClass: IDispatcherConstructor = null,
|
||||
singleton = true
|
||||
dispatcherOptions: INopeDispatcherOptions,
|
||||
options: {
|
||||
constructorClass?: IDispatcherConstructor;
|
||||
singleton?: boolean;
|
||||
useBaseServices?: boolean;
|
||||
} = {}
|
||||
): INopeDispatcher {
|
||||
if (constructorClass === null || constructorClass === undefined) {
|
||||
constructorClass = NopeDispatcher;
|
||||
if (
|
||||
options.constructorClass === null ||
|
||||
options.constructorClass === undefined
|
||||
) {
|
||||
options.constructorClass = NopeDispatcher;
|
||||
}
|
||||
|
||||
options = Object.assign(
|
||||
{
|
||||
constructorClass: null,
|
||||
singleton: true,
|
||||
useBaseServices: true,
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
const create = () => {
|
||||
const dispatcher = new constructorClass(
|
||||
options,
|
||||
const dispatcher = new options.constructorClass(
|
||||
dispatcherOptions,
|
||||
() => new NopeObservable()
|
||||
);
|
||||
|
||||
@ -52,13 +67,15 @@ export function getDispatcher(
|
||||
}
|
||||
);
|
||||
|
||||
addAllBaseServices(dispatcher);
|
||||
if (options.useBaseServices) {
|
||||
addAllBaseServices(dispatcher);
|
||||
}
|
||||
|
||||
// Return the Dispathcer
|
||||
return dispatcher as INopeDispatcher;
|
||||
};
|
||||
|
||||
if (singleton) {
|
||||
if (options.singleton) {
|
||||
// Create a singaleton if required.
|
||||
// use the container to receive the
|
||||
// singleton object
|
||||
|
@ -17,7 +17,7 @@ import {
|
||||
DISPATCHER_INSTANCE,
|
||||
DISPATCHER_OPTIONS,
|
||||
OBSERVABLE_FACTORY,
|
||||
OBSERVABLE_INSTANCE,
|
||||
OBSERVABLE_INSTANCE
|
||||
} from "../symbols/identifiers";
|
||||
import { INopeDispatcherOptions } from "../types/nope/nopeDispatcher.interface";
|
||||
import { IPackageDescription } from "../types/nope/nopePackage.interface";
|
||||
@ -58,7 +58,9 @@ export function generateNopeBasicPackage(
|
||||
selector: TYPES.dispatcher,
|
||||
// We want to provide in this Situation allways the same dispatcher.
|
||||
// type: !singleton ? InjectableNopeDispatcher : getDispatcher(options, null, singleton),
|
||||
type: getDispatcher(options, null, singleton),
|
||||
type: getDispatcher(options, {
|
||||
singleton
|
||||
}),
|
||||
options: {
|
||||
// Shouldn't be required:
|
||||
// scope: singleton ? "inSingletonScope" : undefined,
|
||||
|
@ -21,7 +21,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
@ -38,7 +38,10 @@
|
||||
"const localDispatcher = nope.dispatcher.getDispatcher({\n",
|
||||
" communicator,\n",
|
||||
" id: \"local\"\n",
|
||||
"}, null, false);"
|
||||
"}, {\n",
|
||||
" singleton: false,\n",
|
||||
" useBaseServices: false\n",
|
||||
"});"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -52,9 +55,20 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 2,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"true"
|
||||
]
|
||||
},
|
||||
"execution_count": 2,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"$$.async();\n",
|
||||
"// Lets wait for our element to be ready.\n",
|
||||
@ -70,17 +84,16 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 3,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"// Subscribe to changes\n",
|
||||
"const observer = localDispatcher.connectivityManager.dispatchers.onChange.subscribe(data => {\n",
|
||||
" // Log the changes\n",
|
||||
" console.log(\"onChange - listener\");\n",
|
||||
" console.log(\"-------------------\");\n",
|
||||
" console.log(\"added =\", data.added);\n",
|
||||
" console.log(\"removed =\", data.removed);\n",
|
||||
" console.log((new Date()).toISOString(),\"onChange - listener\");\n",
|
||||
" console.log(\"\\tadded =\", data.added);\n",
|
||||
" console.log(\"\\tremoved =\", data.removed);\n",
|
||||
"});"
|
||||
]
|
||||
},
|
||||
@ -93,9 +106,18 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 4,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"connectedDispatchers = [ 'local' ]\n",
|
||||
"localDispatcherIncluded = true\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"// Show our connected Dispatchers\n",
|
||||
"let connectedDispatchers = localDispatcher.connectivityManager.dispatchers.data.getContent();\n",
|
||||
@ -115,15 +137,28 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 5,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"2022-01-20T11:39:55.766Z onChange - listener\n",
|
||||
"\tadded = [ 'remote' ]\n",
|
||||
"\tremoved = []\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"// 2. Dispatcher simulates our remote system\n",
|
||||
"const remoteDispatcher = nope.dispatcher.getDispatcher({\n",
|
||||
" communicator,\n",
|
||||
" id: \"remote\"\n",
|
||||
"}, null, false);\n"
|
||||
"}, {\n",
|
||||
" singleton: false,\n",
|
||||
" useBaseServices: false\n",
|
||||
"});\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -135,9 +170,17 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 6,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"master = local\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"// We expect to be the master, because the localDispatcher has been created first.\n",
|
||||
"console.log(\"master =\", localDispatcher.connectivityManager.master.id);"
|
||||
@ -152,7 +195,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 7,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
@ -162,14 +205,43 @@
|
||||
"localDispatcher.connectivityManager.isMaster = false;\n",
|
||||
"\n",
|
||||
"// Our messaging is async ==> we wait an amount of time\n",
|
||||
"setTimeout($$.done,1000)"
|
||||
"setTimeout(() => $$.done(),1000);"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 8,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"master = remote\n",
|
||||
"master-info = {\n",
|
||||
" id: 'remote',\n",
|
||||
" env: 'javascript',\n",
|
||||
" version: '1.0.0',\n",
|
||||
" isMaster: true,\n",
|
||||
" host: {\n",
|
||||
" cores: 8,\n",
|
||||
" cpu: {\n",
|
||||
" model: 'Intel(R) Core(TM) i7-8565U CPU',\n",
|
||||
" speed: 1992,\n",
|
||||
" usage: 0.0038778477944740875\n",
|
||||
" },\n",
|
||||
" os: 'win32',\n",
|
||||
" ram: { usedPerc: 0.362681220626356, free: 20676, total: 32442 },\n",
|
||||
" name: 'nz-078'\n",
|
||||
" },\n",
|
||||
" pid: 18068,\n",
|
||||
" timestamp: 1642678798813,\n",
|
||||
" upTime: 3049,\n",
|
||||
" status: 0\n",
|
||||
"}\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"// We expect the master to be the remote.\n",
|
||||
"console.log(\"master =\", localDispatcher.connectivityManager.master.id);\n",
|
||||
@ -185,39 +257,73 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 9,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"2022-01-20T11:40:01.089Z master-info = 0\n",
|
||||
"2022-01-20T11:40:01.789Z master-info = 1\n",
|
||||
"2022-01-20T11:40:02.536Z master-info = 2\n",
|
||||
"2022-01-20T11:40:03.543Z master-info = 3\n",
|
||||
"2022-01-20T11:40:03.977Z onChange - listener\n",
|
||||
"\tadded = []\n",
|
||||
"\tremoved = [ 'remote' ]\n",
|
||||
"2022-01-20T11:40:04.547Z onChange - listener\n",
|
||||
"\tadded = [ 'remote' ]\n",
|
||||
"\tremoved = []\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"$$.async()\n",
|
||||
"\n",
|
||||
"localDispatcher.connectivityManager.setTimings({\n",
|
||||
" // our system will send every 50 ms an heartbeat.\n",
|
||||
" sendAliveInterval: 500,\n",
|
||||
" // we will check that after\n",
|
||||
" checkInterval: 25,\n",
|
||||
" // will mark dispatchers as slow after not receiving heartbeats for 50ms\n",
|
||||
" slow: 50,\n",
|
||||
" // we will mark dispatchers with a warning flag after 50 ms\n",
|
||||
" warn: 100,\n",
|
||||
" // we mark it as dead after 0.5 s\n",
|
||||
" dead: 500,\n",
|
||||
" // We will remove the dispatcher after 1 s\n",
|
||||
" remove: 1000,\n",
|
||||
"});\n",
|
||||
"\n",
|
||||
"const renderStatus = () => {\n",
|
||||
" console.log(\"master-info =\", localDispatcher.connectivityManager.master.status)\n",
|
||||
" console.log((new Date()).toISOString(),\"master-info =\", localDispatcher.connectivityManager.master.status)\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"setTimeout(renderStatus, 75);\n",
|
||||
"setTimeout(renderStatus, 250);\n",
|
||||
"setTimeout(renderStatus, 50);\n",
|
||||
"setTimeout(renderStatus, 750);\n",
|
||||
"setTimeout(renderStatus, 1500);\n",
|
||||
"setTimeout(renderStatus, 2500);\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"localDispatcher.connectivityManager.setTimings({\n",
|
||||
" // our system will send every 50 ms an heartbeat.\n",
|
||||
" sendAliveInterval: 250,\n",
|
||||
" // we will check that after\n",
|
||||
" checkInterval: 125,\n",
|
||||
" // will mark dispatchers as slow after not receiving heartbeats for 50ms\n",
|
||||
" slow: 500,\n",
|
||||
" // we will mark dispatchers with a warning flag after 50 ms\n",
|
||||
" warn: 1000,\n",
|
||||
" // we mark it as dead after 0.5 s\n",
|
||||
" dead: 2000,\n",
|
||||
" // We will remove the dispatcher after 1 s\n",
|
||||
" remove: 3000,\n",
|
||||
"});\n",
|
||||
"\n",
|
||||
"remoteDispatcher.connectivityManager.setTimings({\n",
|
||||
" // our system will send every 50 ms an heartbeat.\n",
|
||||
" sendAliveInterval: 5000,\n",
|
||||
"});\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"// We reset the timeouts.\n",
|
||||
"setTimeout(() => localDispatcher.connectivityManager.setTimings({}), 1200);\n",
|
||||
"setTimeout(() => $$.done(), 2000);\n"
|
||||
"setTimeout(() => localDispatcher.connectivityManager.setTimings({}), 3000);\n",
|
||||
"setTimeout(() => remoteDispatcher.connectivityManager.setTimings({}), 3000);\n",
|
||||
"setTimeout(() => $$.done(), 5000);\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
|
Loading…
Reference in New Issue
Block a user