├── .gitignore
├── Architecture.png
├── README.md
├── backend
├── .dockerignore
├── .eslintrc.json
├── Dockerfile
├── package-lock.json
├── package.json
├── src
│ ├── MaterializeClient
│ │ ├── TailStream
│ │ │ └── index.ts
│ │ ├── TransformStream
│ │ │ └── index.ts
│ │ ├── WriteStream
│ │ │ └── index.ts
│ │ └── index.ts
│ └── app.ts
└── tsconfig.json
├── docker-compose.yml
├── frontend
├── .dockerignore
├── .gitignore
├── Dockerfile
├── README.md
├── craco.config.js
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── App.test.tsx
│ ├── App.tsx
│ ├── ColorModeSwitcher.tsx
│ ├── Logo.tsx
│ ├── components
│ │ └── AntennasMap
│ │ │ ├── ButtonSelection
│ │ │ └── index.js
│ │ │ ├── DotClone
│ │ │ └── index.js
│ │ │ └── index.tsx
│ ├── index.tsx
│ ├── link.ts
│ ├── logo.svg
│ ├── react-app-env.d.ts
│ ├── reportWebVitals.ts
│ ├── serviceWorker.ts
│ ├── setupTests.ts
│ ├── test-utils.tsx
│ └── theme.ts
├── tsconfig.json
├── webpack.config.js
└── yarn.lock
├── helper
├── .dockerignore
├── .eslintrc.json
├── Dockerfile
├── package-lock.json
├── package.json
├── src
│ └── app.ts
└── tsconfig.json
├── microservice
├── .dockerignore
├── .eslintrc.json
├── Dockerfile
├── package-lock.json
├── package.json
├── src
│ └── app.ts
└── tsconfig.json
└── postgres
├── Dockerfile
├── create.sh
├── create.sql
├── rollback.sql
├── seed.sh
└── seed.sql
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/Architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joacoc/antennas-manhattan/9626ff0e82caed24f93724f4b2ff70623ab22a90/Architecture.png
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Manhattan Antennas Performance
2 |
3 | https://github.com/joacoc/antennas-manhattan/assets/11491779/38d5a50b-b6e5-4596-8b0c-c6a4e0cd08bd
4 |
5 | If you want to try it right now, clone the project and run:
6 |
7 | ```
8 | docker-compose up
9 | ```
10 |
11 | After a successful build:
12 |
13 | ```
14 | # Check in your browser
15 | localhost:3000
16 |
17 | # Alternatively connect to:
18 | # Materialize
19 | psql postgresql://materialize:materialize@localhost:6875/materialize
20 |
21 | # Postgres
22 | psql postgresql://postgres:pg_password@localhost:5432/postgres
23 | ```
24 |
25 | ---
26 |
27 | ## Let’s begin.
28 |
29 | An infrastructure working safe and healthy is critical. We, developers, know this very well. In other businesses, like in software, there are vital infrastructures, such as mobile antennas (4G, 5G) in telecommunications companies.
30 | If there is some issue, it needs to be detected and fixed quickly; otherwise, customers will complain, or even worse, move to the competition (churn rate is serious business).
31 |
32 | Antennas manufacturers share [key performance indicators](https://www.ericsson.com/en/reports-and-papers/white-papers/performance-verification-for-5g-nr-deployments) with their telecommunications companies clients. Let's call all these indicators "performance". Rather than setting a 5G antenna manually to provide indicators, let randomness generate this value, providing even more excitement and entertainment to the case than in real life.
33 |
34 | Each antenna has a fixed range where is capable of serving clients. In a map, a green, yellow, or red (healthy, semi-healthy, and unhealthy) circle will denote this area.
35 |
36 | If the last-half-minute average performance is greater than 5, the antenna is healthy.
37 | If it is greater than 4.75 but less than 5, it is semi-healthy.
38 | If it is less than 4.75, the antenna is unhealthy.
39 |
40 | In case an antenna is unhealthy beyond a period of seconds, a whole set of helper antennas will be deployed to improve the performance in the area. After a few seconds of improvement they will be deactivated.
41 |
42 | All this information needs to be processed, analyzed, and served, and that's where Materialize will do the work for us efficiently.
43 |
44 | ## Detailes steps
45 |
46 | There are different ways to achieve a result like this one using Materialize, but for this case, the following strategy fulfill our needs:
47 |
48 | 1. Postgres, where all the base data resides.
49 | 2. Materialize to process and serve the antenna's performance.
50 | 3. Helper process to generate the antennas random data and initialize Materialize
51 | 4. Node.js GraphQL API connects to Materialize using [tails](https://materialize.com/docs/sql/tail/#conceptual-framework).
52 | 5. React front-end displaying the information using GraphQL subscriptions.
53 | 6. Microservice deploying and pushing helper antennas when performance is low
54 |
55 | _Our source, Postgres, could be alternatively replaced with any other [Materialize source](https://materialize.com/docs/sql/create-source/#conceptual-framework)_
56 |
57 | 
58 |
59 |
60 |
61 | 1. To begin with, Postgres needs to be up and running. You can reuse this [custom image with SQLs and shell scripts](https://github.com/MaterializeInc/developer-experience/tree/main/mz-playground/postgres-graphql/postgres) that will get executed in [Postgres initialization](https://github.com/docker-library/docs/blob/master/postgres/README.md#initialization-scripts).
The scripts creates the schemas and defines everything we need to use them as a source:
62 |
63 | ```sql
64 | -- Antennas table will contain the identifier and geojson for each antenna.
65 | CREATE TABLE antennas (
66 | antenna_id INT GENERATED ALWAYS AS IDENTITY,
67 | geojson JSON NOT NULL
68 | );
69 |
70 |
71 | -- Antennas performance table will contain every performance update available
72 | CREATE TABLE antennas_performance (
73 | antenna_id INT,
74 | clients_connected INT NOT NULL,
75 | performance INT NOT NULL,
76 | updated_at timestamp NOT NULL
77 | );
78 |
79 |
80 | -- Enable REPLICA for both tables
81 | ALTER TABLE antennas REPLICA IDENTITY FULL;
82 | ALTER TABLE antennas_performance REPLICA IDENTITY FULL;
83 |
84 |
85 | -- Create publication on the created tables
86 | CREATE PUBLICATION antennas_publication_source FOR TABLE antennas, antennas_performance;
87 |
88 |
89 | -- Create user and role to be used by Materialize
90 | CREATE ROLE materialize REPLICATION LOGIN PASSWORD 'materialize';
91 | GRANT SELECT ON antennas, antennas_performance TO materialize;
92 | ```
93 |
94 |
95 |
96 | 2-3. Once Postgres is up and running, Materialize will be ready to consume it. If you are automating a deployment, a [helper process](https://github.com/MaterializeInc/developer-experience/blob/main/mz-playground/postgres-graphql/helper/src/app.ts) can do the job to set up sources and views in Materialize and also feed Postgres indefinitely with data.
The SQL script to build Materialize schema is the next one:
97 |
98 | ```sql
99 | -- All these queries run inside the helper process.
100 |
101 |
102 | -- Create the Postgres Source
103 | CREATE MATERIALIZED SOURCE IF NOT EXISTS antennas_publication_source
104 | FROM POSTGRES
105 | CONNECTION 'host=postgres port=5432 user=materialize password=materialize dbname=postgres'
106 | PUBLICATION 'antennas_publication_source';
107 |
108 |
109 | -- Turn the Postgres tables into Materialized Views
110 | CREATE MATERIALIZED VIEWS FROM SOURCE antennas_publication_source;
111 |
112 |
113 | -- Filter last half minute updates
114 | CREATE MATERIALIZED VIEW IF NOT EXISTS last_half_minute_updates AS
115 | SELECT A.antenna_id, A.geojson, performance, AP.updated_at, ((CAST(EXTRACT( epoch from AP.updated_at) AS NUMERIC) * 1000) + 30000)
116 | FROM antennas A JOIN antennas_performance AP ON (A.antenna_id = AP.antenna_id)
117 | WHERE ((CAST(EXTRACT( epoch from AP.updated_at) AS NUMERIC) * 1000) + 30000) > mz_logical_timestamp();
118 |
119 |
120 | -- Aggregate by anntena ID and GeoJSON to obtain the average performance in the last half minute.
121 | CREATE MATERIALIZED VIEW IF NOT EXISTS last_half_minute_performance_per_antenna AS
122 | SELECT antenna_id, geojson, AVG(performance) as performance
123 | FROM last_half_minute_updates
124 | GROUP BY antenna_id, geojson;
125 | ```
126 |
127 | Antennas data generation statement:
128 |
129 | ```sql
130 | -- Insert data using the helper process.
131 | INSERT INTO antennas_performance (antenna_id, clients_connected, performance, updated_at) VALUES (
132 | ${antennaId},
133 | ${Math.ceil(Math.random() * 100)},
134 | ${Math.random() * 10},
135 | now()
136 | );
137 | ```
138 |
139 | 4. Now, the information should be ready to consume.
140 | The back-end works with [Graphql-ws](https://github.com/enisdenjo/graphql-ws). Subscriptions and tails go together like Bonnie and Clyde. Multiple applications send ongoing events to the front-end with sockets or server-sent events (SSE), becoming super handy to use with `tails`. Rather than constantly sending queries back-and-forth, we can run a single `tail last_half_minute_performance_per_antenna with (snapshot)` and send the results more efficiently.
141 | The back-end will use a modified client to run these tails. It implements internally [Node.js stream interfaces](https://nodejs.org/api/stream.html) to handle [backpressure](https://github.com/MaterializeInc/developer-experience/blob/main/mz-playground/postgres-graphql/backend/src/MaterializeClient/TailStream/index.ts), create one second batches and group all the changes in one map [(summary)](https://github.com/MaterializeInc/developer-experience/blob/main/mz-playground/postgres-graphql/backend/src/MaterializeClient/TransformStream/index.ts).
142 |
143 | 5. The front-end doesn't require going deep since it will consist of only one component. Apollo GraphQL subscribes to our back-end, and the antennas information gets displayed in a list and a visual map. The frequency at which the information updates is every one second.
144 |
145 | 6. The microservice behaves similar to the front-end. Rather than connecting directly to Materialize, it will subscribe to the GraphQL API and subscribe to the antenna's performance. Once a low performance has been detected multiple times a set of helper antennas will be deployed.
146 |
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
--------------------------------------------------------------------------------
/backend/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": [
7 | "eslint:recommended",
8 | "plugin:@typescript-eslint/recommended"
9 | ],
10 | "parser": "@typescript-eslint/parser",
11 | "parserOptions": {
12 | "ecmaVersion": "latest",
13 | "sourceType": "module"
14 | },
15 | "plugins": [
16 | "@typescript-eslint"
17 | ],
18 | "rules": {
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16
2 |
3 | # Create app directory
4 | WORKDIR /usr/src/app
5 |
6 | # Install app dependencies
7 | # A wildcard is used to ensure both package.json AND package-lock.json are copied
8 | # where available (npm@5+)
9 | COPY package*.json ./
10 |
11 | RUN npm install
12 |
13 | # Bundle app source
14 | COPY . .
15 |
16 | EXPOSE 4000
17 | CMD [ "npm", "start" ]
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "backend",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "dist/app.js",
6 | "scripts": {
7 | "start": "tsc && node dist/app.js",
8 | "lint": "eslint . --ext .ts",
9 | "test": "echo \"Error: no test specified\" && exit 1"
10 | },
11 | "keywords": [],
12 | "author": "",
13 | "license": "ISC",
14 | "devDependencies": {
15 | "@typescript-eslint/eslint-plugin": "^5.10.1",
16 | "@typescript-eslint/parser": "^5.10.1",
17 | "eslint": "^8.7.0",
18 | "typescript": "^4.5.5"
19 | },
20 | "dependencies": {
21 | "@types/pg": "^8.6.4",
22 | "apollo-server-express": "^3.6.2",
23 | "express": "^4.17.3",
24 | "graphql": "^16.3.0",
25 | "graphql-ws": "^5.5.5",
26 | "pg": "^8.7.3",
27 | "ws": "^8.4.2"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/backend/src/MaterializeClient/TailStream/index.ts:
--------------------------------------------------------------------------------
1 | import { Readable } from "stream";
2 | import { Client } from "pg";
3 |
4 | /**
5 | * Thanks to Petros Angelatos
6 | * https://gist.github.com/petrosagg/804e5f009dee1cb8af688654ba396258
7 | * This class reads from a cursor in PostgreSQL
8 | */
9 | export default class TailStream extends Readable {
10 | client: Client;
11 |
12 | cursorId: string;
13 |
14 | pendingReads: number;
15 |
16 | currentRows: Array;
17 |
18 | BreakException = {};
19 |
20 | intervalId: NodeJS.Timer;
21 |
22 | runningQuery: boolean;
23 |
24 | constructor(client: Client, cursorId: string) {
25 | super({
26 | highWaterMark: 1000,
27 | objectMode: true,
28 | });
29 | this.client = client;
30 | this.cursorId = cursorId;
31 | this.pendingReads = 0;
32 | this.runningQuery = false;
33 | }
34 |
35 | /**
36 | * Readable method to fetch tail data
37 | * @param n
38 | */
39 | _read(n: number): void {
40 | if (this.pendingReads <= 0) {
41 | this.client
42 | .query(`FETCH ${n} ${this.cursorId} WITH (TIMEOUT='1s');`)
43 | .then(({ rows, rowCount }) => {
44 | if (rowCount === 0) {
45 | console.log("Empty results from tail. Staring interval read.");
46 | /**
47 | * Wait for data from the tail
48 | */
49 | this.intervalId = setInterval(() => this.intervalRead(n), 500);
50 | } else {
51 | /**
52 | * Process data
53 | */
54 | this.process(rows);
55 | }
56 | })
57 | .catch(this.catchClientErr);
58 | } else {
59 | /**
60 | * Process any additional rows
61 | */
62 | this.currentRows = this.currentRows.slice(
63 | this.currentRows.length - this.pendingReads,
64 | this.currentRows.length
65 | );
66 | try {
67 | this.currentRows.forEach((row) => {
68 | this.pendingReads -= 1;
69 | const backPressure = !this.push(row);
70 | if (backPressure) {
71 | throw this.BreakException;
72 | }
73 | });
74 | } catch (e) {
75 | if (e !== this.BreakException) throw e;
76 | }
77 | }
78 | }
79 |
80 | /**
81 | * Capture any error while fetching tail results
82 | * @param clientReasonErr
83 | */
84 | catchClientErr(clientReasonErr: any) {
85 | console.error("Error querying this cursor.");
86 | console.error(clientReasonErr);
87 |
88 | if (this.intervalId) {
89 | clearInterval(this.intervalId);
90 | }
91 |
92 | this.destroy(clientReasonErr);
93 | }
94 |
95 | /**
96 | * Process and push rows
97 | * @param rows
98 | */
99 | process(rows: Array): void {
100 | try {
101 | rows.forEach((row) => {
102 | this.pendingReads -= 1;
103 | const backPressure = !this.push(row);
104 | if (backPressure) {
105 | console.log("Oops. Backpressure.");
106 | throw this.BreakException;
107 | }
108 | });
109 | } catch (e) {
110 | if (e !== this.BreakException) throw e;
111 | }
112 | }
113 |
114 | /**
115 | * Interval fetching used when there are no results from the TAIL
116 | * Rather than pausing and waiting for results
117 | * Run a tail fetch every 500ms.
118 | * This is needed because if there is no update from the tail the pipe will close.
119 | * Another alternative is to send dummy data but this could end up filtering data all the time.
120 | * Another alternative is to push whenever is available rather than "poll" but how backpressure is handled?
121 | * @param n
122 | */
123 | intervalRead(n: number): void {
124 | if (this.runningQuery === false) {
125 | if (this.destroyed) {
126 | clearInterval(this.intervalId);
127 | return;
128 | }
129 |
130 | this.runningQuery = true;
131 | this.client
132 | .query(`FETCH ${n} ${this.cursorId} WITH (TIMEOUT='1s');`)
133 | .then(({ rows, rowCount }) => {
134 | if (rowCount > 0) {
135 | this.process(rows);
136 | clearInterval(this.intervalId);
137 | console.log("New results from the tail. Finishing interval read.");
138 | } else {
139 | console.log("Nothing from interval read.");
140 | }
141 | })
142 | .catch(this.catchClientErr)
143 | .finally(() => {
144 | this.runningQuery = false;
145 | });
146 | }
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/backend/src/MaterializeClient/TransformStream/index.ts:
--------------------------------------------------------------------------------
1 | import { Transform } from "stream";
2 |
3 | interface Antenna {
4 | antenna_id: string;
5 | geojson: string;
6 | performance: number;
7 | }
8 |
9 | /**
10 | * This class creates a batch of chunks. In this way every chunk is not a row but an array of rows.
11 | * This will improve the performance of the writing.
12 | * A timeout is needed in case the batch length is lower than the highwatermark for a long period of time.
13 | */
14 | export default class TransformStream extends Transform {
15 | batch = new Array();
16 |
17 | size: number;
18 |
19 | constructor() {
20 | super({
21 | highWaterMark: 100,
22 | objectMode: true,
23 | });
24 |
25 | this.cleanBatch();
26 | }
27 |
28 | cleanBatch() {
29 | this.batch = new Array();
30 | }
31 |
32 | _transform(row: any, encoding: string, callback: () => void) {
33 | const { mz_progressed: mzProgressed } = row;
34 |
35 | if (mzProgressed) {
36 | this.push(this.batch);
37 | this.cleanBatch();
38 | } else {
39 | this.batch.push(row);
40 | }
41 | callback();
42 | }
43 |
44 | _flush(callback: () => void) {
45 | if (this.batch.length) {
46 | this.push(this.batch);
47 | this.cleanBatch();
48 | }
49 | callback();
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/backend/src/MaterializeClient/WriteStream/index.ts:
--------------------------------------------------------------------------------
1 | import { Writable } from "stream";
2 |
3 | /**
4 | * This class is in charge of writing every chunk (array of rows)
5 | * into a redis instance to send to all the users.
6 | */
7 | export default class WriteStream extends Writable {
8 | listener: (results: Array) => void;
9 |
10 | constructor(listener: (results: Array) => void) {
11 | super({
12 | highWaterMark: 1000,
13 | objectMode: true,
14 | });
15 |
16 | this.listener = listener;
17 | }
18 |
19 | _write(
20 | rows: Array,
21 | encoding: BufferEncoding,
22 | callback: (error?: Error) => void
23 | ): void {
24 | if (rows && rows.length > 0) {
25 | this.listener(rows);
26 | }
27 |
28 | callback();
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/backend/src/MaterializeClient/index.ts:
--------------------------------------------------------------------------------
1 | import { Pool, QueryResult, Client as PgClient, PoolConfig } from "pg";
2 | import EventEmitter from "events";
3 | import TailStream from "./TailStream";
4 | import TransformStream from "./TransformStream";
5 | import WriteStream from "./WriteStream";
6 |
7 | /**
8 | * Custom Materialize Client created for Demo purposes
9 | */
10 | export default class MaterializeClient {
11 | pool: Pool | undefined;
12 | config: PoolConfig;
13 |
14 | constructor(config: PoolConfig) {
15 | this.config = config;
16 | this.pool = new Pool(config);
17 | }
18 |
19 | /**
20 | * Run a query in Materialize
21 | * @param statement
22 | * @returns
23 | */
24 | query(statement: string): Promise {
25 | const queryPromise = new Promise((res, rej) => {
26 | this.pool.connect(async (connectErr, poolClient, release) => {
27 | try {
28 | /**
29 | * Check errors in connection
30 | */
31 | if (connectErr) {
32 | console.error(`Error connecting pool to run query.`);
33 | rej(connectErr);
34 | return;
35 | }
36 |
37 | /**
38 | * Control pool error listeners
39 | */
40 | if (poolClient.listenerCount("error") === 0) {
41 | poolClient.on("error", (clientErr) => {
42 | console.error(`Client err: ${clientErr}`);
43 | rej(new Error("Error running query."));
44 | });
45 | }
46 |
47 | /**
48 | * Run query
49 | */
50 | const response = await poolClient.query(statement);
51 | res(response);
52 | } catch (e) {
53 | console.error("Error running query.");
54 | console.error(e);
55 | rej(e);
56 | } finally {
57 | try {
58 | /**
59 | * After we stop/destroy a client release function dissapears.
60 | */
61 | if (release) {
62 | release();
63 | }
64 | } catch (e) {
65 | console.error(e);
66 | console.error("Error realeasing client.");
67 | }
68 | }
69 | });
70 | });
71 |
72 | return queryPromise;
73 | }
74 |
75 | /**
76 | * Run a tail in Materialize
77 | * @param statement
78 | * @param eventEmmiter
79 | * @returns
80 | */
81 | async tail(statement: string, eventEmmiter: EventEmitter): Promise {
82 | return new Promise((res, rej) => {
83 | const asyncStream = async () => {
84 | /**
85 | * Create a single client per tail rather than re-using a pool's client
86 | */
87 | const singleClient = new PgClient(this.config);
88 |
89 | /**
90 | * Client ending handler
91 | */
92 | let clientEnded = false;
93 | const endClient = () => {
94 | console.log("Ending client.");
95 | if (clientEnded === false) {
96 | clientEnded = true;
97 | singleClient.end((err) => {
98 | if (err) {
99 | console.error("Error ending client.");
100 | console.debug(err);
101 | }
102 | });
103 | }
104 | };
105 |
106 | try {
107 | singleClient.on("error", (clientErr) => {
108 | console.error(`Client err: ${clientErr}`);
109 | rej(clientErr);
110 | });
111 |
112 | singleClient.on("end", () => {
113 | console.log("Client end.");
114 | res();
115 | });
116 |
117 | await singleClient.connect();
118 | await singleClient.query(
119 | `BEGIN; DECLARE mz_cursor CURSOR FOR ${statement} WITH (SNAPSHOT, PROGRESS);`
120 | );
121 |
122 | /**
123 | * Listen to tail data updates
124 | */
125 | const listener = (results: Array) => {
126 | eventEmmiter.emit("data", results);
127 | };
128 |
129 | /**
130 | * Listen to tail errors
131 | */
132 | const handleTailError = (err) => {
133 | console.error("Error inside tail: ", err);
134 | rej(err);
135 | };
136 |
137 | const tailStream = new TailStream(singleClient, "mz_cursor");
138 | tailStream.on("error", handleTailError);
139 |
140 | const transfromStream = new TransformStream();
141 | const writeStream = new WriteStream(listener);
142 |
143 | /**
144 | * Listen to disconnects from the client
145 | */
146 | eventEmmiter.on("disconnect", () => {
147 | if (tailStream.destroyed === false) {
148 | tailStream.destroy();
149 | }
150 | endClient();
151 | });
152 |
153 | /**
154 | * Listen to pipe closing due to success or failure
155 | */
156 | const streamPipe = tailStream.pipe(transfromStream).pipe(writeStream);
157 | streamPipe.on("close", () => {
158 | console.log("Pipe closed");
159 | endClient();
160 | res();
161 | });
162 | } catch (clientError) {
163 | endClient();
164 | rej(clientError);
165 | }
166 | };
167 |
168 | asyncStream().catch((asyncErr) => {
169 | console.error("Error inside async stream.");
170 | console.error(asyncErr);
171 | rej(asyncErr);
172 | });
173 | });
174 | }
175 | }
176 |
--------------------------------------------------------------------------------
/backend/src/app.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { WebSocketServer } from "ws";
3 | import { Extra, useServer } from "graphql-ws/lib/use/ws";
4 | import { buildSchema, parse, validate } from "graphql";
5 | import MaterializeClient from "./MaterializeClient";
6 | import EventEmitter from "events";
7 | import { Pool } from "pg";
8 | import { Context, SubscribeMessage } from "graphql-ws";
9 |
10 | /**
11 | * Materialize Client
12 | */
13 | const materializeClient = new MaterializeClient({
14 | // host: "localhost",
15 | host: "materialized",
16 | port: 6875,
17 | user: "materialize",
18 | password: "materialize",
19 | database: "materialize",
20 | query_timeout: 5000,
21 | });
22 |
23 | /**
24 | * Postgres Client
25 | */
26 | const postgresPool = new Pool({
27 | // host: "localhost",
28 | host: "postgres",
29 | port: 5432,
30 | user: "postgres",
31 | password: "pg_password",
32 | database: "postgres",
33 | });
34 |
35 | /**
36 | * Build GraphQL Schema
37 | */
38 | const schema = buildSchema(`
39 | type Antenna {
40 | antenna_id: String
41 | geojson: String
42 | performance: Float
43 | diff: Int
44 | timestamp: Float
45 | }
46 |
47 | type Query {
48 | getAntennas: [Antenna]
49 | }
50 |
51 | type Mutation {
52 | crashAntenna(antenna_id: String!): Antenna
53 | }
54 |
55 | type Subscription {
56 | antennasUpdates: [Antenna]
57 | }
58 | `);
59 |
60 | /**
61 | * Map to follow connections and tails
62 | */
63 | const connectionEventEmitter = new EventEmitter();
64 |
65 | /**
66 | * Build a custom Postgres insert with a low performance value to crash antenna
67 | * @param antennaId Antenna Identifier
68 | * @returns
69 | */
70 | function buildQuery(antennaId: number) {
71 | return `
72 | INSERT INTO antennas_performance (antenna_id, clients_connected, performance, updated_at) VALUES (
73 | ${antennaId},
74 | ${Math.ceil(Math.random() * 100)},
75 | -100,
76 | now()
77 | );
78 | `;
79 | }
80 |
81 | /**
82 | * Queries
83 | */
84 | const getAntennas = async () => {
85 | try {
86 | const { rows } = await materializeClient.query("SELECT * FROM antennas;");
87 |
88 | /**
89 | * Stringify GEOJson
90 | */
91 | const mappedRows = rows.map((x) => ({
92 | ...x,
93 | geojson: JSON.stringify(x.geojson),
94 | }));
95 | return mappedRows;
96 | } catch (err) {
97 | console.log("Error running query.");
98 | console.error(err);
99 | }
100 |
101 | return "Hello!";
102 | };
103 |
104 | /**
105 | * Mutations
106 | */
107 | const crashAntenna = async (context) => {
108 | const { antenna_id: antennaId } = context;
109 |
110 | postgresPool.connect(async (err, client, done) => {
111 | if (err) {
112 | console.error(err);
113 | return;
114 | }
115 |
116 | try {
117 | /**
118 | * Smash the performance
119 | */
120 | const query = buildQuery(antennaId);
121 |
122 | await client.query(query);
123 | } catch (clientErr) {
124 | console.error(clientErr);
125 | } finally {
126 | done();
127 | }
128 | });
129 |
130 | return {
131 | antenna_id: antennaId,
132 | };
133 | };
134 |
135 | /**
136 | * Subscriptions
137 | */
138 | async function* antennasUpdates(_, ctxVars) {
139 | const [subscriptionId] = ctxVars;
140 |
141 | try {
142 | /**
143 | * Yield helpers
144 | */
145 | let results = [];
146 | let resolve: (value: unknown) => void;
147 | let promise = new Promise((r) => (resolve = r));
148 | let done = false;
149 |
150 | /**
151 | * Listen tail events
152 | */
153 | const eventEmmiter = new EventEmitter();
154 | eventEmmiter.on("data", (data) => {
155 | const mappedData: Array = data.map((x) => ({
156 | ...x,
157 | geojson: JSON.stringify(x.geojson),
158 | diff: x.mz_diff,
159 | timestamp: x.mz_timestamp,
160 | }));
161 | results = mappedData;
162 | resolve(mappedData);
163 | promise = new Promise((r) => (resolve = r));
164 | });
165 |
166 | materializeClient
167 | .tail(
168 | "TAIL (SELECT * FROM last_half_minute_performance_per_antenna)",
169 | eventEmmiter
170 | )
171 | .catch((tailErr) => {
172 | console.error("Error running tail.");
173 | console.error(tailErr);
174 | })
175 | .finally(() => {
176 | console.log("Finished tail.");
177 | done = true;
178 | });
179 |
180 | connectionEventEmitter.on("disconnect", (unsubscriptionId) => {
181 | if (subscriptionId === unsubscriptionId) {
182 | eventEmmiter.emit("disconnect");
183 | done = true;
184 | }
185 | });
186 |
187 | /**
188 | * Yield results
189 | */
190 | while (!done) {
191 | await promise;
192 | yield { antennasUpdates: results };
193 | results = [];
194 | }
195 |
196 | console.log("Outside done.");
197 | } catch (error) {
198 | console.error("Error running antennas updates subscription.");
199 | console.error(error);
200 | }
201 | }
202 |
203 | /**
204 | * The roots provide resolvers for each GraphQL operation
205 | */
206 | const roots = {
207 | query: {
208 | getAntennas,
209 | },
210 | mutation: {
211 | crashAntenna,
212 | },
213 | subscription: {
214 | antennasUpdates,
215 | },
216 | };
217 |
218 | /**
219 | * Connection handlers
220 | */
221 | const onClose = () => {
222 | console.log("onClose ids");
223 | };
224 | const onConnect = () => {
225 | console.log("onConnect.");
226 | };
227 | const onDisconnect = (ctx) => {
228 | const ids = Object.keys(ctx.subscriptions);
229 | ids.forEach((id) => connectionEventEmitter.emit("disconnect", id));
230 | };
231 | const onError = (ctx, msg, errors) => {
232 | console.error("onError: ", ctx, msg, errors);
233 | };
234 | const onSubscribe: (
235 | ctx: Context>>,
236 | message: SubscribeMessage
237 | ) => any = (ctx, msg) => {
238 | const ids = Object.keys(ctx.subscriptions);
239 | console.log("OnSubscribe ids: ", ids);
240 |
241 | const args = {
242 | schema,
243 | operationName: msg.payload.operationName,
244 | document: parse(msg.payload.query),
245 | variableValues: msg.payload.variables,
246 | };
247 |
248 | // dont forget to validate when returning custom execution args!
249 | const errors = validate(args.schema, args.document);
250 | if (errors.length > 0) {
251 | return errors; // return `GraphQLError[]` to send `ErrorMessage` and stop subscription
252 | }
253 |
254 | return { ...args, contextValue: ids };
255 | };
256 |
257 | /**
258 | * Setup server
259 | */
260 | const app = express();
261 |
262 | const server = app.listen(4000, () => {
263 | const wsServer = new WebSocketServer({
264 | server,
265 | path: "/graphql",
266 | });
267 |
268 | wsServer.on("error", (serverError) => {
269 | console.error("Server error: ", serverError);
270 | });
271 |
272 | wsServer.on("connection", (ws) => {
273 | ws.on("error", (socketError) => {
274 | console.error("Socket error: ", socketError);
275 | });
276 | });
277 |
278 | useServer(
279 | { schema, roots, onClose, onDisconnect, onError, onSubscribe, onConnect },
280 | wsServer
281 | );
282 |
283 | console.log(
284 | "🚀 GraphQL web socket server listening on port 4000. \n\nUse 'ws://localhost:4000/graphql' to connect."
285 | );
286 | });
287 |
--------------------------------------------------------------------------------
/backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "esModuleInterop": true,
5 | "target": "es6",
6 | "moduleResolution": "node",
7 | "sourceMap": true,
8 | "outDir": "dist",
9 | "lib": ["esnext.asynciterable"]
10 | },
11 | "lib": ["es2015"]
12 | }
13 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | postgres:
4 | container_name: postgres
5 | build:
6 | context: ./postgres
7 | ports:
8 | - 5432:5432
9 | restart: always
10 | environment:
11 | POSTGRES_PASSWORD: pg_password
12 | command:
13 | - "postgres"
14 | - "-c"
15 | - "wal_level=logical"
16 | materialized:
17 | image: materialize/materialized:v0.20.0
18 | container_name: materialized
19 | restart: always
20 | ports:
21 | - 6875:6875
22 | depends_on:
23 | - postgres
24 | grafana_dashboard:
25 | image: materialize/dashboard
26 | container_name: grafana_dashboard
27 | restart: always
28 | ports:
29 | - 3001:3000
30 | depends_on:
31 | - materialized
32 | environment:
33 | MATERIALIZED_URL: materialized:6875
34 | helper:
35 | container_name: helper
36 | build:
37 | context: ./helper
38 | depends_on:
39 | - materialized
40 | backend:
41 | container_name: backend
42 | build:
43 | context: ./backend
44 | ports:
45 | - 4000:4000
46 | depends_on:
47 | - materialized
48 | frontend:
49 | container_name: frontend
50 | build:
51 | context: ./frontend
52 | ports:
53 | - 3000:3000
54 | depends_on:
55 | - backend
56 | microservice:
57 | container_name: microservice
58 | build:
59 | context: ./microservice
60 | depends_on:
61 | - backend
62 |
--------------------------------------------------------------------------------
/frontend/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | # Specify a base image
2 | FROM node:16 as build-deps
3 |
4 | # Create working directory and copy the app before running yarn install as the artifactory
5 | # credentials can be inside .npmrc
6 | WORKDIR /usr/src/app
7 | COPY . ./
8 |
9 | # Run yarn install
10 | RUN yarn install
11 |
12 | # Build the project
13 | RUN yarn build
14 |
15 | # Install serve command for yarn package manager
16 | RUN yarn global add serve
17 |
18 | # Start the application
19 | CMD serve -p 3000 ./build
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | This project was bootstrapped with
2 | [Create React App](https://github.com/facebook/create-react-app).
3 |
4 | ## Available Scripts
5 |
6 | In the project directory, you can run:
7 |
8 | ### `yarn start`
9 |
10 | Runs the app in the development mode. Open
11 | [http://localhost:3000](http://localhost:3000) to view it in the browser.
12 |
13 | The page will reload if you make edits. You will also see any lint errors
14 | in the console.
15 |
16 | ### `yarn test`
17 |
18 | Launches the test runner in the interactive watch mode. See the section
19 | about
20 | [running tests](https://facebook.github.io/create-react-app/docs/running-tests)
21 | for more information.
22 |
23 | ### `yarn build`
24 |
25 | Builds the app for production to the `build` folder. It correctly bundles
26 | React in production mode and optimizes the build for the best performance.
27 |
28 | The build is minified and the filenames include the hashes. Your app is
29 | ready to be deployed!
30 |
31 | See the section about
32 | [deployment](https://facebook.github.io/create-react-app/docs/deployment) for
33 | more information.
34 |
35 | ### `yarn eject`
36 |
37 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
38 |
39 | If you aren’t satisfied with the build tool and configuration choices, you can
40 | `eject` at any time. This command will remove the single build dependency from
41 | your project.
42 |
43 | Instead, it will copy all the configuration files and the transitive
44 | dependencies (webpack, Babel, ESLint, etc) right into your project so you have
45 | full control over them. All of the commands except `eject` will still work, but
46 | they will point to the copied scripts so you can tweak them. At this point
47 | you’re on your own.
48 |
49 | You don’t have to ever use `eject`. The curated feature set is suitable for
50 | small and middle deployments, and you shouldn’t feel obligated to use this
51 | feature. However we understand that this tool wouldn’t be useful if you couldn’t
52 | customize it when you are ready for it.
53 |
54 | ## Learn More
55 |
56 | You can learn more in the
57 | [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
58 |
59 | To learn React, check out the [React documentation](https://reactjs.org/).
60 |
--------------------------------------------------------------------------------
/frontend/craco.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | babel: {
3 | loaderOptions: {
4 | ignore: ["./node_modules/mapbox-gl/dist/mapbox-gl.js"],
5 | },
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@apollo/client": "^3.5.8",
7 | "@chakra-ui/react": "^1.7.4",
8 | "@craco/craco": "^6.4.3",
9 | "@emotion/react": "^11.0.0",
10 | "@emotion/styled": "^11.0.0",
11 | "@testing-library/jest-dom": "^5.9.0",
12 | "@testing-library/react": "^10.2.1",
13 | "@testing-library/user-event": "^12.0.2",
14 | "@types/jest": "^25.0.0",
15 | "@types/node": "^12.0.0",
16 | "@types/react": "^16.9.0",
17 | "@types/react-dom": "^16.9.0",
18 | "framer-motion": "^4.0.0",
19 | "graphql": "^16.2.0",
20 | "graphql-ws": "^5.5.5",
21 | "mapbox-gl": "^2.7.0",
22 | "react": "^17.0.2",
23 | "react-dom": "^17.0.2",
24 | "react-icons": "^3.0.0",
25 | "react-map-gl": "^6.1.19",
26 | "react-scripts": "5.0.0",
27 | "typescript": "^4.3.5",
28 | "web-vitals": "^0.2.2"
29 | },
30 | "scripts": {
31 | "start": "craco start",
32 | "build": "craco build",
33 | "test": "craco test"
34 | },
35 | "eslintConfig": {
36 | "extends": "react-app"
37 | },
38 | "browserslist": {
39 | "production": [
40 | ">0.2%",
41 | "not dead",
42 | "not op_mini all"
43 | ],
44 | "development": [
45 | "last 1 chrome version",
46 | "last 1 firefox version",
47 | "last 1 safari version"
48 | ]
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joacoc/antennas-manhattan/9626ff0e82caed24f93724f4b2ff70623ab22a90/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
18 |
22 |
26 |
27 |
36 | Antennas Performance
37 |
38 |
39 |
40 |
41 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/frontend/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joacoc/antennas-manhattan/9626ff0e82caed24f93724f4b2ff70623ab22a90/frontend/public/logo192.png
--------------------------------------------------------------------------------
/frontend/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joacoc/antennas-manhattan/9626ff0e82caed24f93724f4b2ff70623ab22a90/frontend/public/logo512.png
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/frontend/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { screen } from "@testing-library/react"
3 | import { render } from "./test-utils"
4 | import { App } from "./App"
5 |
6 | test("renders learn react link", () => {
7 | render()
8 | const linkElement = screen.getByText(/learn chakra/i)
9 | expect(linkElement).toBeInTheDocument()
10 | })
11 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ChakraProvider, Box, Text } from "@chakra-ui/react";
3 |
4 | import { ApolloClient, InMemoryCache, ApolloProvider } from "@apollo/client";
5 | import AntennasMap from "./components/AntennasMap";
6 | import link from "./link";
7 | import theme from "./theme";
8 |
9 | const client = new ApolloClient({
10 | uri: "backend:4000/graphql",
11 | cache: new InMemoryCache(),
12 | link,
13 | });
14 |
15 | export const App = () => (
16 |
17 |
18 |
25 |
26 | 🗽 Manhattan 5G Antennas Performance
27 |
28 |
29 |
30 |
31 |
32 | );
33 |
--------------------------------------------------------------------------------
/frontend/src/ColorModeSwitcher.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import {
3 | useColorMode,
4 | useColorModeValue,
5 | IconButton,
6 | IconButtonProps,
7 | } from "@chakra-ui/react"
8 | import { FaMoon, FaSun } from "react-icons/fa"
9 |
10 | type ColorModeSwitcherProps = Omit
11 |
12 | export const ColorModeSwitcher: React.FC = (props) => {
13 | const { toggleColorMode } = useColorMode()
14 | const text = useColorModeValue("dark", "light")
15 | const SwitchIcon = useColorModeValue(FaMoon, FaSun)
16 |
17 | return (
18 | }
26 | aria-label={`Switch to ${text} mode`}
27 | {...props}
28 | />
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/Logo.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import {
3 | chakra,
4 | keyframes,
5 | ImageProps,
6 | forwardRef,
7 | usePrefersReducedMotion,
8 | } from "@chakra-ui/react"
9 | import logo from "./logo.svg"
10 |
11 | const spin = keyframes`
12 | from { transform: rotate(0deg); }
13 | to { transform: rotate(360deg); }
14 | `
15 |
16 | export const Logo = forwardRef((props, ref) => {
17 | const prefersReducedMotion = usePrefersReducedMotion()
18 |
19 | const animation = prefersReducedMotion
20 | ? undefined
21 | : `${spin} infinite 20s linear`
22 |
23 | return
24 | })
25 |
--------------------------------------------------------------------------------
/frontend/src/components/AntennasMap/ButtonSelection/index.js:
--------------------------------------------------------------------------------
1 | export const buildButtonSelection = (mapBox) => {
2 | mapBox.on('idle', () => {
3 | // If these two layers were not added to the map, abort
4 | if (!mapBox.getLayer('healthy-antennas-layer') || !mapBox.getLayer('unhealthy-antennas-layer')) {
5 | return;
6 | }
7 |
8 | // Enumerate ids of the layers.
9 | const toggleableLayerIds = ['healthy-antennas-layer', 'unhealthy-antennas-layer'];
10 |
11 | // Set up the corresponding toggle button for each layer.
12 | toggleableLayerIds.forEach((layerId) => {
13 | // Skip layers that already have a button set up.
14 | if (document.getElementById(layerId)) {
15 | return;
16 | }
17 |
18 | // Create a link.
19 | const link = document.createElement('a');
20 | link.id = layerId;
21 | link.href = '#';
22 | link.textContent = layerId;
23 | link.className = 'active';
24 |
25 | // Show or hide layer when the toggle is clicked.
26 | // eslint-disable-next-line no-loop-func
27 | link.onclick = function (e) {
28 | const clickedLayer = this.textContent;
29 | e.preventDefault();
30 | e.stopPropagation();
31 |
32 | const visibility = mapBox.getLayoutProperty(
33 | clickedLayer,
34 | 'visibility'
35 | );
36 |
37 | // Toggle layer visibility by changing the layout object's visibility property.
38 | if (visibility === 'visible') {
39 | mapBox.setLayoutProperty(clickedLayer, 'visibility', 'none');
40 | this.className = '';
41 | } else {
42 | this.className = 'active';
43 | mapBox.setLayoutProperty(
44 | clickedLayer,
45 | 'visibility',
46 | 'visible'
47 | );
48 | }
49 | };
50 |
51 | const layers = document.getElementById('menu');
52 | layers.appendChild(link);
53 | })
54 | });
55 | };
56 |
--------------------------------------------------------------------------------
/frontend/src/components/AntennasMap/DotClone/index.js:
--------------------------------------------------------------------------------
1 | export const buildPulsingDot = (map, color, size) => {
2 | return {
3 | width: size,
4 | height: size,
5 | data: new Uint8Array(size * size * 4),
6 |
7 | // get rendering context for the map canvas when layer is added to the map
8 | onAdd: function () {
9 | var canvas = document.createElement("canvas");
10 | canvas.width = this.width;
11 | canvas.height = this.height;
12 | this.context = canvas.getContext("2d");
13 | },
14 |
15 | // called once before every frame where the icon will be used
16 | render: function () {
17 | var duration = 1750;
18 | var t = (performance.now() % duration) / duration;
19 |
20 | var radius = (size / 2) * 0.3;
21 | var outerRadius = (size / 2) * 0.7 * t + radius;
22 | var context = this.context;
23 |
24 | // draw outer circle
25 | context.clearRect(0, 0, this.width, this.height);
26 | context.beginPath();
27 | context.arc(this.width / 2, this.height / 2, outerRadius, 0, Math.PI * 2);
28 | context.fillStyle = `rgba(${color}, ${1 - t})`;
29 | context.fill();
30 |
31 | // draw inner circle
32 | context.beginPath();
33 | context.arc(this.width / 2, this.height / 2, radius / 3, 0, Math.PI * 2);
34 | context.fillStyle = `rgba(${color}, 1)`;
35 | context.fill();
36 |
37 | // update this image's data with data from the canvas
38 | this.data = context.getImageData(0, 0, this.width, this.height).data;
39 |
40 | // continuously repaint the map, resulting in the smooth animation of the dot
41 | map.triggerRepaint();
42 |
43 | // return `true` to let the map know that the image was updated
44 | return true;
45 | },
46 | };
47 | };
48 |
--------------------------------------------------------------------------------
/frontend/src/components/AntennasMap/index.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | useRef,
3 | useEffect,
4 | useCallback,
5 | useMemo,
6 | useState,
7 | } from "react";
8 | import { buildPulsingDot } from "./DotClone";
9 | import { gql, useMutation, useSubscription } from "@apollo/client";
10 | import { Button, Box, ListItem, Text, UnorderedList } from "@chakra-ui/react";
11 | import mapboxgl, { Map as MapBox } from "mapbox-gl";
12 |
13 | /**
14 | * Subscription
15 | */
16 | const SUBSCRIBE_ANTENNAS = gql`
17 | subscription AntennasUpdates {
18 | antennasUpdates {
19 | antenna_id
20 | geojson
21 | performance
22 | diff
23 | timestamp
24 | }
25 | }
26 | `;
27 |
28 | /**
29 | * Mutation
30 | */
31 | const MUTATE_ANTENNAS = gql`
32 | mutation Mutation($antenna_id: String!) {
33 | crashAntenna(antenna_id: $antenna_id) {
34 | antenna_id
35 | }
36 | }
37 | `;
38 |
39 | interface GeoJSON {
40 | type: string;
41 | geometry: {
42 | type: string;
43 | coordinates: [number, number];
44 | };
45 | properties: {
46 | name: string;
47 | helps?: string;
48 | };
49 | }
50 |
51 | interface Antenna extends BaseAntenna {
52 | geojson: GeoJSON;
53 | }
54 |
55 | interface RawAntenna extends BaseAntenna {
56 | geojson: string;
57 | }
58 |
59 | interface BaseAntenna {
60 | antenna_id: string;
61 | performance: number;
62 | diff: number;
63 | timestamp: number;
64 | }
65 |
66 | interface AntennasUpdatesSubscription {
67 | antennasUpdates: Array;
68 | }
69 |
70 | /**
71 | * Set up a source data
72 | * @param map MapBox
73 | * @param sourceName Map source name
74 | * @param data Data to set
75 | */
76 | function setSourceData(
77 | map: MapBox,
78 | sourceName: string,
79 | data: Array
80 | ): void {
81 | const source = map.getSource(sourceName);
82 |
83 | if (source) {
84 | (source as any).setData({
85 | type: "FeatureCollection",
86 | features: data,
87 | });
88 | }
89 | }
90 |
91 | /**
92 | *
93 | * @param map MapBox
94 | * @param name Image layer name
95 | * @param source Source name
96 | * @param color Color to be used
97 | */
98 | function addPulsingDot(
99 | map: any,
100 | name: string,
101 | source: string,
102 | color: string,
103 | size?: number
104 | ) {
105 | (map.current as any).addImage(
106 | name,
107 | buildPulsingDot(map.current as any, color, size || 30),
108 | {
109 | pixelRatio: 2,
110 | }
111 | );
112 |
113 | (map.current as any).addLayer({
114 | id: name,
115 | type: "symbol",
116 | source: source,
117 | layout: {
118 | "icon-image": name,
119 | },
120 | });
121 | }
122 |
123 | /**
124 | * Replace with your own MapBox token
125 | */
126 | function REPLACE_ME_WITH_YOUR_TOKEN() {
127 | return (
128 | "pk" +
129 | ".ey" +
130 | "J1Ijo" +
131 | "iam9hcXVpbmNvbGFjY2kiLCJhIjoiY2t6N2Z4M2pzMWExcTJvdHYxc3k4MzFveSJ9.QSm7ZtegpUwuZ1MCbt4dIg"
132 | );
133 | }
134 |
135 | /**
136 | * Tail updates require another step here to detect added and removed antennas
137 | * @param antennasMap
138 | * @param update
139 | */
140 | function handleTailEventUpdate(
141 | event: Antenna,
142 | antennasMap: Map,
143 | markSet: Set,
144 | runSet: Set
145 | ) {
146 | const {
147 | antenna_id: antennaId,
148 | diff,
149 | timestamp: updateTimestamp,
150 | performance: antennaPerformance,
151 | } = event;
152 | markSet.delete(antennaId);
153 | const lastEvent = antennasMap.get(antennaId);
154 |
155 | if (lastEvent) {
156 | const { timestamp: lastTimestamp, performance: lastPerformance } =
157 | lastEvent;
158 |
159 | if (diff > 0 && lastTimestamp < updateTimestamp) {
160 | antennasMap.set(antennaId, event);
161 | } else if (
162 | (lastPerformance === antennaPerformance &&
163 | lastTimestamp <= updateTimestamp) ||
164 | lastTimestamp < updateTimestamp
165 | ) {
166 | runSet.add(antennaId);
167 | }
168 | } else if (diff > 0) {
169 | antennasMap.set(antennaId, event);
170 | }
171 | }
172 |
173 | function createLayer(
174 | id: string,
175 | source: string,
176 | color: string
177 | ): mapboxgl.AnyLayer {
178 | return {
179 | id,
180 | type: "circle",
181 | source,
182 | paint: {
183 | "circle-radius": 70,
184 | "circle-color": color,
185 | "circle-opacity": 0.3,
186 | },
187 | filter: ["==", "$type", "Point"],
188 | };
189 | }
190 |
191 | /**
192 | * React component that renders antennas performance in a list and a map.
193 | * @returns
194 | */
195 | export default function AntennasMap() {
196 | /**
197 | * References
198 | */
199 | const mapContainer = useRef(null);
200 | const map = useRef(null);
201 | const dataRef = useRef(undefined);
202 | const [antennasMap, setAntennasMap] = useState