Merge pull request #172 from openmhz/stats-display

fixes for slow
This commit is contained in:
Luke Berndt
2024-09-15 09:36:15 -04:00
committed by GitHub
18 changed files with 3100 additions and 3132 deletions

View File

@@ -1,11 +1,19 @@
/** Important **/
/** You should not be committing this file to GitHub **/
/** Repeat: DO! NOT! COMMIT! THIS! FILE! TO! YOUR! REPO! **/
var host = process.env['MONGO_NODE_DRIVER_HOST'] != null ? process.env['MONGO_NODE_DRIVER_HOST'] : 'localhost';
var port = process.env['MONGO_NODE_DRIVER_PORT'] != null ? process.env['MONGO_NODE_DRIVER_PORT'] : 27017;
var mongoUrl = 'mongodb://' + host + ':' + port + '/scanner';
const mongo_host = typeof process.env['MONGO_HOST'] !== 'undefined' ? process.env['MONGO_HOST'] : 'mongo';
const mongo_port = typeof process.env['MONGO_PORT'] !== 'undefined' ? process.env['MONGO_PORT'] : 27017;
const mongo_user = process.env['MONGO_USER'];
const mongo_password = process.env['MONGO_PASSWORD'];
let mongoUrl;
if ((typeof mongo_user !== 'undefined') && (typeof mongo_password !== 'undefined')) {
console.log("Using authentication for MongoDB - user: " + mongo_user);
mongoUrl = 'mongodb://' + mongo_user + ':' + mongo_password + '@' + mongo_host + ':' + mongo_port + '/scanner';
} else {
mongoUrl = 'mongodb://' + mongo_host + ':' + mongo_port + '/scanner';
}
const secrets = {
db: mongoUrl,

View File

@@ -42,8 +42,9 @@ const Login = (props) => {
const loginSubmit = async (event) => {
event.preventDefault();
const result = await dispatch(loginUser({ email, password })).unwrap();
const user = result.user;
if (result.success) {
if ((pathname != "/terms") && (terms != 1.1)) {
if ((pathname != "/terms") && (user.terms != 1.1)) {
navigate("/terms")
} else if (nextLocation) {
switch (nextLocation) {
@@ -130,7 +131,7 @@ const Login = (props) => {
href="mailto:{process.env.REACT_APP_ADMIN_EMAIL}?Subject={process.env.REACT_APP_SITE_NAME}"
target="_top"
>
admin@email.com
{process.env.REACT_APP_ADMIN_EMAIL}
</a>{" "}
</p>
</Grid.Column>

View File

@@ -1,11 +1,19 @@
/** Important **/
/** You should not be committing this file to GitHub **/
/** Repeat: DO! NOT! COMMIT! THIS! FILE! TO! YOUR! REPO! **/
var host = process.env['MONGO_NODE_DRIVER_HOST'] != null ? process.env['MONGO_NODE_DRIVER_HOST'] : 'localhost';
var port = process.env['MONGO_NODE_DRIVER_PORT'] != null ? process.env['MONGO_NODE_DRIVER_PORT'] : 27017;
var mongoUrl = 'mongodb://' + host + ':' + port + '/scanner';
const mongo_host = typeof process.env['MONGO_HOST'] !== 'undefined' ? process.env['MONGO_HOST'] : 'mongo';
const mongo_port = typeof process.env['MONGO_PORT'] !== 'undefined' ? process.env['MONGO_PORT'] : 27017;
const mongo_user = process.env['MONGO_USER'];
const mongo_password = process.env['MONGO_PASSWORD'];
let mongoUrl;
if ((typeof mongo_user !== 'undefined') && (typeof mongo_password !== 'undefined')) {
console.log("Using authentication for MongoDB - user: " + mongo_user);
mongoUrl = 'mongodb://' + mongo_user + ':' + mongo_password + '@' + mongo_host + ':' + mongo_port + '/scanner';
} else {
mongoUrl = 'mongodb://' + mongo_host + ':' + mongo_port + '/scanner';
}
module.exports = secrets = {
db: mongoUrl,

View File

@@ -79,7 +79,7 @@ exports.exportTalkgroups = function (req, res, next) {
async function csv_import(shortName, filename, callback) {
var talkgroups = [];
const count = await Talkgroup.deleteMany({ shortName: shortName });
var parser = parse(
{
@@ -90,6 +90,7 @@ async function csv_import(shortName, filename, callback) {
console.error("Error: csv import 2: " + err);
callback(err, null);
} else {
const count = await Talkgroup.deleteMany({ shortName: shortName });
var response = [];
for (var i = 0; i < data.length; i++) {
@@ -97,7 +98,7 @@ async function csv_import(shortName, filename, callback) {
if (!( "Decimal" in row) || !("Description" in row) || !("Alpha Tag" in row)) {
console.error("Error: csv import column header error");
console.error(row)
callback("Column Headers are required - Required Columns: 'Decimal' 'Decscription' 'Alpha Tag' Optional Column: 'Priority' ", null);
callback("Column Headers are required - Required Columns: 'Decimal' 'Description' 'Alpha Tag' Optional Column: 'Priority' ", null);
return
}

View File

@@ -399,6 +399,7 @@ const System = () => {
<em>
The Talkgroup file is a .CSV file that has infromation about the different talkgroups on the system. Headers are required for the columns, and should be the first
line of the file. The columns can be in any order, but the headers must match exactly.<br/>
<p>If you export the file from Excel, make sure to use "Comma Separated Values" and NOT "CSV UTF-8" for the File Format.</p>
An example header line is: <br />
<code>Decimal,Alpha Tag,Description</code><br/>
<p>There can be additional columns so you can use the same Talkgroup CSV file from Trunk Recorder.</p>

View File

@@ -2,11 +2,19 @@
const opentelemetry = require('@opentelemetry/sdk-node');
const { MongoDBInstrumentation } = require('@opentelemetry/instrumentation-mongodb');
const { TraceIdRatioBasedSampler } = require('@opentelemetry/sdk-trace-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { diag, DiagConsoleLogger, DiagLogLevel } = require('@opentelemetry/api');
const {
Detector,
DetectorSync,
IResource,
ResourceDetectionConfig,
envDetectorSync,
hostDetectorSync,
processDetectorSync,
} = require("@opentelemetry/resources")
// const {
// dockerCGroupV1Detector,
// } = require('@opentelemetry/resource-detector-docker');
@@ -20,6 +28,19 @@ console.log("OTEL_EXPORTER_OTLP_HEADERS: " + process.env.OTEL_EXPORTER_OTLP_HEAD
console.log("OTEL_SERVICE_NAME: " + process.env.OTEL_SERVICE_NAME);
// from https://github.com/open-telemetry/opentelemetry-js/issues/4638
// this is a workaround to wait for async attributes to be resolved
function awaitAttributes(detector){
return {
async detect(config) {
const resource = detector.detect(config)
await resource.waitForAsyncAttributes()
return resource
},
}
}
const samplePercentage = 0.05;
const sdk = new opentelemetry.NodeSDK({
@@ -37,6 +58,11 @@ const sdk = new opentelemetry.NodeSDK({
},
}),
],
resourceDetectors: [
awaitAttributes(envDetectorSync),
awaitAttributes(processDetectorSync),
awaitAttributes(hostDetectorSync),
],
//resourceDetectors: [dockerCGroupV1Detector],
});

View File

@@ -14,6 +14,7 @@ var fs = require('fs');
const { PutObjectCommand, S3Client } = require("@aws-sdk/client-s3");
const { fromIni } = require("@aws-sdk/credential-providers");
const e = require('express');
var s3_endpoint = process.env['S3_ENDPOINT'] != null ? process.env['S3_ENDPOINT'] : 'https://s3.us-west-1.wasabisys.com';
var s3_region = process.env['S3_REGION'] != null ? process.env['S3_REGION'] : 'us-west-1';
@@ -177,43 +178,47 @@ const createPodcast = async (tmpEventFolder, podcastFile, event) => {
inputs.forEach((value, i) => {
concat = concat + "file '" + value + "'\n";
});
const concatFile = tmpEventFolder + "/FILES.txt"
fs.writeFileSync(concatFile, concat, "utf8");
// https://video.stackexchange.com/questions/21315/concatenating-split-media-files-using-concat-protocol
//ffmpeg -f concat -safe 0 -i mylist.txt -c copy output
command.push("-hide_banner");
command.push("-loglevel");
command.push("error");
command.push("-f");
command.push("concat");
command.push("-safe");
command.push("0");
command.push("-i");
command.push(concatFile);
command.push("-acodec");
command.push("aac");
command.push(tmpFile);
await ffmpeg(command);
try {
const concatFile = tmpEventFolder + "/FILES.txt"
fs.writeFileSync(concatFile, concat, "utf8");
// https://video.stackexchange.com/questions/21315/concatenating-split-media-files-using-concat-protocol
//ffmpeg -f concat -safe 0 -i mylist.txt -c copy output
command.push("-hide_banner");
command.push("-loglevel");
command.push("error");
command.push("-f");
command.push("concat");
command.push("-safe");
command.push("0");
command.push("-i");
command.push(concatFile);
command.push("-acodec");
command.push("aac");
command.push(tmpFile);
await ffmpeg(command);
const chapters = createChapters(event);
const chaptersFile = tmpEventFolder + "/CHAPTERS.txt"
fs.writeFileSync(chaptersFile, chapters, "utf8");
command = [];
command.push("-hide_banner");
command.push("-loglevel");
command.push("error");
command.push("-i");
command.push(tmpFile)
command.push("-i");
command.push(chaptersFile)
command.push("-map_metadata")
command.push("1")
command.push("-codec");
command.push("copy");
command.push(podcastFile)
//console.log("Running command: " + command);
await ffmpeg(command);
const chapters = createChapters(event);
const chaptersFile = tmpEventFolder + "/CHAPTERS.txt"
fs.writeFileSync(chaptersFile, chapters, "utf8");
command = [];
command.push("-hide_banner");
command.push("-loglevel");
command.push("error");
command.push("-i");
command.push(tmpFile)
command.push("-i");
command.push(chaptersFile)
command.push("-map_metadata")
command.push("1")
command.push("-codec");
command.push("copy");
command.push(podcastFile)
//console.log("Running command: " + command);
await ffmpeg(command);
} catch (e) {
console.error("Error creating podcast " + e);
}
}
const cleanupEvent = (folder, filename) => {
@@ -382,11 +387,12 @@ exports.addNewEvent = async function (req, res, next) {
const savedEvent = await event.save();
//console.log("Event ID " + savedEvent._id);
const url = "/events/" + event._id;
res.contentType('json');
res.send(JSON.stringify({ url: url }));
await packageEvent(savedEvent._id);
res.contentType('json');
res.send(JSON.stringify({ url: url }));
}
catch (err) {
console.error("Error creating event" + err);

View File

@@ -1,13 +1 @@
const Group = require("../models/group");
exports.get_groups = async function (req, res) {
const grp_results = await Group.find({ shortName: req.params.shortName.toLowerCase()}).sort("position").catch(err => {
console.error(err);
res.status(500);
res.json({ success: false, message: err });
return;
});
res.contentType('json');
res.send(JSON.stringify(grp_results));
}

View File

@@ -1,20 +1,53 @@
const Talkgroup = require("../models/talkgroup");
const Group = require("../models/group");
exports.get_talkgroups = async function (req, res) {
const tg_results = await Talkgroup.find({ 'shortName': req.params.shortName.toLowerCase()}).catch(err => {
let groups = {};
let talkgroups = {};
async function load_talkgroups(shortName) {
const tg_results = await Talkgroup.find({ 'shortName': shortName }).catch(err => {
console.error(err);
res.status(500);
res.json({ success: false, message: err });
return;
});
var talkgroups = {};
});
let temp = {}
for (var tg in tg_results) {
var talkgroup = {_id: tg_results[tg]._id, num: tg_results[tg].num, alpha: tg_results[tg].alpha, description: tg_results[tg].description}
talkgroups[tg_results[tg].num] = talkgroup;
var talkgroup = { _id: tg_results[tg]._id, num: tg_results[tg].num, alpha: tg_results[tg].alpha, description: tg_results[tg].description }
temp[tg_results[tg].num] = talkgroup;
}
return temp;
}
exports.get_talkgroups = async function (req, res) {
const shortName = req.params.shortName.toLowerCase();
/*
if (talkgroups.hasOwnProperty(shortName)) {
talkgroups = talkgroups[shortName];
} else {
}*/
let talkgroups = await load_talkgroups(shortName);
res.contentType('json');
res.send(JSON.stringify({
talkgroups: talkgroups
}));
}
exports.get_groups = async function (req, res) {
const shortName = req.params.shortName.toLowerCase();
const grp_results = await Group.find({ shortName: req.params.shortName.toLowerCase() }).sort("position").catch(err => {
console.error(err);
res.status(500);
res.json({ success: false, message: err });
return;
});
res.contentType('json');
res.send(JSON.stringify(grp_results));
}

View File

@@ -34,6 +34,13 @@ const client = new S3Client({
exports.upload = async function (req, res, next) {
const tracer = trace.getTracer("upload-service");
let start_time = Date.now();
let validateSystemTime = 0;
let readFileTime = 0;
let uploadFileTime = 0;
let saveCallTime = 0;
let cleanupTime = 0;
let statsTime = 0;
return context.with(context.active(), async () => {
const parentSpan = trace.getActiveSpan(context.active());
await tracer.startActiveSpan('upload_handler', { parent: parentSpan }, async (span) => {
@@ -108,7 +115,7 @@ exports.upload = async function (req, res, next) {
res.status(200).end();
return;
}
validateSystemTime = Date.now();
if (item.ignoreUnknownTalkgroup) {
const talkgroupExists = await Talkgroup.exists({
shortName,
@@ -129,7 +136,7 @@ exports.upload = async function (req, res, next) {
res.status(200).end();
const local_path = `/${shortName}/${time.getFullYear()}/${time.getMonth() + 1}/${time.getDate()}/`;
const object_key = `media/${shortName}-${talkgroupNum}-${startTime}${path.extname(req.file.originalname)}`;
const object_key = `media/${shortName}/${talkgroupNum}/${shortName}-${talkgroupNum}-${startTime}${path.extname(req.file.originalname)}`;
const url = `${s3_public_url}/${object_key}`;
const call = new Call({
@@ -151,10 +158,11 @@ exports.upload = async function (req, res, next) {
});
let fileContent;
await tracer.startActiveSpan('upload_to_s3', { parent: trace.getActiveSpan(context.active()) }, async (uploadSpan) => {
try {
fileContent = fs.readFileSync(req.file.path);
readFileTime = Date.now();
const command = new PutObjectCommand({
Bucket: s3_bucket,
Key: object_key,
@@ -177,7 +185,7 @@ exports.upload = async function (req, res, next) {
uploadSpan.end();
}
});
uploadFileTime = Date.now();
await tracer.startActiveSpan('save_call', { parent: trace.getActiveSpan(context.active()) }, async (saveSpan) => {
try {
await call.save();
@@ -193,7 +201,9 @@ exports.upload = async function (req, res, next) {
}
});
saveCallTime = Date.now();
sysStats.addCall(call.toObject());
statsTime = Date.now();
if (call.len >= 1) {
req.call = call.toObject();
@@ -211,7 +221,8 @@ exports.upload = async function (req, res, next) {
cleanupSpan.end();
}
});
cleanupTime = Date.now();
//console.log(`[${call.shortName}] \t Verify System: ${validateSystemTime - start_time} \t Read file: ${readFileTime - validateSystemTime} \t Upload: ${ uploadFileTime - validateSystemTime} \t Save: ${saveCallTime - uploadFileTime} \tStats: ${statsTime - saveCallTime}\tCleanup: ${cleanupTime - statsTime} \t\t Total: ${cleanupTime - start_time}`);
} catch (error) {
console.error("Error processing call upload: " + error);
span.recordException(error);
@@ -222,6 +233,7 @@ exports.upload = async function (req, res, next) {
} finally {
span.end();
}
});
});
};

View File

@@ -6,8 +6,9 @@ var calls = require("./controllers/calls");
var uploads = require("./controllers/uploads");
var systems = require("./controllers/systems");
var talkgroups = require("./controllers/talkgroups");
var groups = require("./controllers/groups");
var stats = require("./controllers/stats");
var sys_stats = require("./sys_stats");
var events = require("./controllers/events");
var config = require('./config/config.json');
let db = require('./db')
@@ -42,9 +43,19 @@ configureExpress(app)
// -------------------------------------------
var host = process.env['MONGO_NODE_DRIVER_HOST'] != null ? process.env['MONGO_NODE_DRIVER_HOST'] : 'mongo';
var port = process.env['MONGO_NODE_DRIVER_PORT'] != null ? process.env['MONGO_NODE_DRIVER_PORT'] : 27017;
var mongoUrl = 'mongodb://' + host + ':' + port + '/scanner';
const mongo_host = typeof process.env['MONGO_HOST'] !== 'undefined' ? process.env['MONGO_HOST'] : 'mongo';
const mongo_port = typeof process.env['MONGO_PORT'] !== 'undefined' ? process.env['MONGO_PORT'] : 27017;
const mongo_user = process.env['MONGO_USER'];
const mongo_password = process.env['MONGO_PASSWORD'];
let mongoUrl;
if ((typeof mongo_user !== 'undefined') && (typeof mongo_password !== 'undefined')) {
console.log("Using authentication for MongoDB - user: " + mongo_user);
mongoUrl = 'mongodb://' + mongo_user + ':' + mongo_password + '@' + mongo_host + ':' + mongo_port + '/scanner';
} else {
mongoUrl = 'mongodb://' + mongo_host + ':' + mongo_port + '/scanner';
}
const connect = async () => {
@@ -60,7 +71,7 @@ const connect = async () => {
console.log('Mongoose is disconnecting');
});
await mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true });
await mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true, maxPoolSize: 50 });
console.log("All Done");
}
connect();
@@ -94,6 +105,11 @@ function addSystemClients(req, res, next) {
next();
}
function addTotalClients(req, res, next) {
req.totalClients = Object.keys(clients).length;
next();
}
/*------ CALLS ----------*/
@@ -124,7 +140,7 @@ app.post('/:shortName/authorize', systems.authorize_system);
app.get('/:shortName/talkgroups', talkgroups.get_talkgroups);
/*------ GROUPS ----------*/
app.get('/:shortName/groups', groups.get_groups);
app.get('/:shortName/groups', talkgroups.get_groups);
/*------ EVENTS ----------*/
@@ -134,6 +150,7 @@ app.get('/events/:id', events.getEvent);
/*------ STATS ----------*/
app.get('/:shortName/stats', stats.get_stats);
app.get('/stats', addTotalClients, sys_stats.siteStats)
function get_clients(req, res) {

5601
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,11 +6,8 @@
"@aws-sdk/client-s3": "^3.309.0",
"@aws-sdk/credential-provider-process": "^3.306.0",
"@aws-sdk/credential-providers": "^3.309.0",
"@honeycombio/opentelemetry-node": "^0.7.2",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/auto-instrumentations-node": "^0.41.1",
"@opentelemetry/instrumentation-mongodb": "^0.46.0",
"@opentelemetry/resource-detector-docker": "^0.1.2",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/resources": "^1.26.0",
"@smithy/node-http-handler": "^3.1.4",
"adm-zip": "^0.5.10",
"axios": "1.3.5",

View File

@@ -9,12 +9,21 @@ var uploadErrors = {};
let decodeErrorsFreq = {};
const timePeriod = 15; // in minutes
var spots = (24 * 60) / timePeriod; // the number of spots needed to keep track of 24 hours of stats
let uploadsPerMin = new Array(spots).fill(0);
let activeSystems = 0;
let totalClients = 0;
function updateUploadsPerMin(totalUploads) {
uploadsPerMin.push(totalUploads / timePeriod);
uploadsPerMin.shift();
}
async function updateActiveSystems() {
// Go through all of the Systems
let siteTotal = 0;
let activeSystems = 0;
activeSystems = 0;
for await (let item of System.find()) {
// go through all the systems
@@ -33,6 +42,11 @@ async function updateActiveSystems() {
await item.save();
};
updateUploadsPerMin(siteTotal);
// Save talkgroupStats to a JSON file
const filePath = "/data/stats/uploadsPerMin.json";
fs.writeFileSync(filePath, JSON.stringify(uploadsPerMin));
console.log("Saved uploadsPerMin to JSON file");
console.log("Site average uploads per minute: " + siteTotal / timePeriod);
console.log("Active Systems: " + activeSystems);
}
@@ -72,6 +86,22 @@ exports.initStats = async function () {
console.error("Decode Errors File not found: " + decodeErrorsFreqPath);
}
const uploadsPerMinPath = "/data/stats/uploadsPerMin.json";
if (fs.existsSync(uploadsPerMinPath)) {
// Read the contents of the file
const fileContents = fs.readFileSync(uploadsPerMinPath, "utf8");
// Parse the JSON data
try {
uploadsPerMin = JSON.parse(fileContents);
} catch (error) {
console.error("Decode Errors - Error parsing JSON data:", error);
}
} else {
console.error("Decode Errors File not found: " + uploadsPerMinPath);
}
/*
for await (const item of SystemStat.find()) {
const obj = item.toObject();
@@ -324,6 +354,18 @@ exports.shiftStats = async function () {
updateActiveSystems();
console.log("Finished Updating Active Systems at: " + new Date());
}
exports.siteStats = function(req, res) {
const siteStats ={
uploadsPerMin,
activeSystems,
totalClients: req.totalClients
}
res.contentType('json');
res.send(JSON.stringify(siteStats));
};
exports.callTotals = function (shortName) {
return callTotals[shortName];
}

View File

@@ -67,7 +67,10 @@ services:
- OTEL_EXPORTER_OTLP_ENDPOINT
- OTEL_EXPORTER_OTLP_HEADERS
- OTEL_SERVICE_NAME
- MONGO_NODE_DRIVER_HOST=mongo
- MONGO_HOST
- MONGO_PORT
- MONGO_USER
- MONGO_PASSWORD
- MAILJET_KEY
- MAILJET_SECRET
- REACT_APP_ADMIN_EMAIL
@@ -117,7 +120,10 @@ services:
- REACT_APP_ACCOUNT_SERVER
- REACT_APP_FRONTEND_SERVER
- REACT_APP_SITE_NAME
- MONGO_NODE_DRIVER_HOST=mongo
- MONGO_HOST
- MONGO_PORT
- MONGO_USER
- MONGO_PASSWORD
- NODE_ENV
depends_on:
- mongo
@@ -143,7 +149,10 @@ services:
- NODE_ENV
image: "acount:${TAG}-${STAGE}"
environment:
- MONGO_NODE_DRIVER_HOST=mongo
- MONGO_HOST
- MONGO_PORT
- MONGO_USER
- MONGO_PASSWORD
- MAILJET_KEY
- MAILJET_SECRET
- REACT_APP_BACKEND_SERVER

View File

@@ -27,7 +27,7 @@ const tooltipStyles = {
};
// util
const formatDate = timeFormat("%b %d, %H:%m");
const formatDate = timeFormat("%b %d, %H:%MM");
// accessors
const getDate = (d) => {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Link, useNavigate } from 'react-router-dom'
import SystemCard from "../System/SystemCard";
import SupportModal from "../Common/SupportModal";
@@ -16,12 +16,24 @@ import {
Menu,
Segment,
Sidebar,
Loader,
Statistic,
Transition,
} from 'semantic-ui-react'
import { AreaClosed, Line, Bar } from '@visx/shape';
import { curveMonotoneX } from '@visx/curve';
import { GridRows, GridColumns } from '@visx/grid';
import { scaleTime, scaleLinear } from '@visx/scale';
import { withTooltip, Tooltip, TooltipWithBounds, defaultStyles } from '@visx/tooltip';
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
import { localPoint } from '@visx/event';
import { LinearGradient } from '@visx/gradient';
import { max, extent, bisector } from 'd3-array';
import { timeFormat } from 'd3-time-format';
import { AxisLeft } from '@visx/axis';
import { useDispatch } from 'react-redux'
import { useGetSystemsQuery } from "../features/api/apiSlice";
import { useGetStatsQuery, useGetSystemsQuery, useGetSiteStatsQuery } from "../features/api/apiSlice";
/* Responsive component was removed from Semantic UI. This is discussed here: https://github.com/Semantic-Org/Semantic-UI-React/pull/4008 */
@@ -201,6 +213,8 @@ const MobileContainer = (props) => {
</Media>
)
}
/*
MobileContainer.propTypes = {
children: PropTypes.node,
@@ -213,6 +227,254 @@ const ResponsiveContainer = ({ children }) => (
</div>
)
const BetterSiteStatsChart = ({siteStats}) => {
//const props = {data};
const background = '#9f0000'; //'#3b6978';
const background2 = '#9f0000'; //'#204051';
const accentColor = '#edffea';
const accentColorDark = '#ff7543'; //#75daad';
const tooltipStyles = {
...defaultStyles,
background,
border: '1px solid white',
color: 'white',
};
const formatDate = timeFormat("%b %d, %H:%MM");
// accessors
const getDate = (d) => {
return d.x;
}
const getCallActivity = (d) => {
return parseInt(d.y);
}
const {
tooltipData,
tooltipLeft,
tooltipTop,
tooltipOpen,
showTooltip,
hideTooltip,
} = useTooltip();
const width = 500;
const height = 250;
const { containerRef, TooltipInPortal } = useTooltipInPortal({
// use TooltipWithBounds
detectBounds: true,
// when tooltip containers are scrolled, this will correctly update the Tooltip position
scroll: true,
})
const bisectDate = bisector((d) => d.x).left;
const getMouseData = (event) => {
const coords = localPoint(event.target.ownerSVGElement, event);
const { x } = coords;
const x0 = dateScale.invert(x);
const index = bisectDate(calls, x0, 1);
const d0 = calls[index - 1];
const d1 = calls[index];
let d = d0;
if (d1 && getDate(d1)) {
d = x0.valueOf() - getDate(d0).valueOf() > getDate(d1).valueOf() - x0.valueOf() ? d1 : d0;
}
return { data: d, coords }
}
const handleMouseOver = (event, datum) => {
const { coords, data } = getMouseData(event);
showTooltip({
tooltipLeft: coords.x,
tooltipTop: callActivityScale(getCallActivity(data)),
tooltipData: data
});
};
// bounds
const margin = { top: 10, right: 0, bottom: 0, left: 25 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const calls = useMemo(
() => {
let callTotals = [];
const now = new Date();
var MS_PER_MINUTE = 60000;
for (let j = 0; j < siteStats.length; j++) {
let spotsBack = siteStats.length - j;
let time = new Date(now - spotsBack * 15 * MS_PER_MINUTE);
callTotals.push({ y: siteStats[j], x: time });
}
callTotals.sort((a,b) => a.x-b.x);
return callTotals
}, [siteStats]
);
const dateScale = useMemo(
() =>
scaleTime({
range: [margin.left, innerWidth + margin.left],
domain: extent(calls, getDate),
}),
[innerWidth, margin.left, calls],
);
const callActivityScale = useMemo(
() => {
return scaleLinear({
range: [innerHeight + margin.top, margin.top],
domain: [0, (max(calls, getCallActivity) || 0)], // + innerHeight / 3],
nice: true,
})
},
[margin.top, innerHeight, calls],
);
return (
// Set `ref={containerRef}` on the element corresponding to the coordinate system that
// `left/top` (passed to `TooltipInPortal`) are relative to.
<div style={{position: 'relative'}}>
<svg ref={containerRef} width={width} height={height} >
<rect
x={margin.left}
y={0}
width={width-margin.left-margin.right}
height={height}
fill="url(#area-background-gradient)"
rx={14}
/>
<LinearGradient id="area-background-gradient" from={background} to={background2} />
<LinearGradient id="area-gradient" from={accentColor} to={accentColor} toOpacity={0.1} />
<GridRows
left={margin.left}
scale={callActivityScale}
width={innerWidth}
strokeDasharray="1,3"
stroke={accentColor}
strokeOpacity={0}
pointerEvents="none"
/>
<GridColumns
top={margin.top}
scale={dateScale}
height={innerHeight}
strokeDasharray="1,3"
stroke={accentColor}
strokeOpacity={0.2}
pointerEvents="none"
/>
<AreaClosed
data={calls}
x={(d) => dateScale(getDate(d)) ?? 0}
y={(d) => callActivityScale(getCallActivity(d)) ?? 0}
yScale={callActivityScale}
strokeWidth={1}
stroke="url(#area-gradient)"
fill="url(#area-gradient)"
curve={curveMonotoneX}
/>
<Bar
x={margin.left}
y={margin.top}
width={innerWidth}
height={innerHeight}
fill="transparent"
rx={14}
onMouseMove={handleMouseOver}
onMouseOut={hideTooltip}
/>
<AxisLeft scale={callActivityScale} left={margin.left} numTicks={2} hideAxisLine={true} hideZero={true} />
{tooltipData && (
<g>
<Line
from={{ x: tooltipLeft, y: margin.top }}
to={{ x: tooltipLeft, y: innerHeight + margin.top }}
stroke={accentColorDark}
strokeWidth={2}
pointerEvents="none"
strokeDasharray="5,2"
/>
<circle
cx={tooltipLeft}
cy={tooltipTop + 1}
r={4}
fill="black"
fillOpacity={0.1}
stroke="black"
strokeOpacity={0.1}
strokeWidth={2}
pointerEvents="none"
/>
<circle
cx={tooltipLeft}
cy={tooltipTop}
r={4}
fill={accentColorDark}
stroke="white"
strokeWidth={2}
pointerEvents="none"
/>
</g>
)}
</svg>
{tooltipOpen && (
<div>
<TooltipWithBounds
key={Math.random()}
top={tooltipTop - 12}
left={tooltipLeft}
style={tooltipStyles}
>
{`${getCallActivity(tooltipData)}`}
</TooltipWithBounds>
<Tooltip
top={-28}
left={tooltipLeft}
style={{
...defaultStyles,
minWidth: 72,
textAlign: 'center',
transform: 'translateX(-50%)',
}}
>
{formatDate(getDate(tooltipData))}
</Tooltip>
</div>
)}
</div>
)
};
const SiteStatsContainer = () => {
const { data: siteStats, isSuccess: siteStatsSuccess } = useGetSiteStatsQuery();
if (!siteStatsSuccess) {
return <Loader size='large'>Loading Site Stats</Loader>;
}
return (
<div>
<h2>Site Activity</h2>
<BetterSiteStatsChart siteStats={siteStats.uploadsPerMin} />
Calls per Minute
</div>
);
};
// ----------------------------------------------------
const Main = (props) => {
@@ -220,6 +482,8 @@ const Main = (props) => {
const [currentSystem, setCurrentSystem] = useState(0);
const navigate = useNavigate();
const { data: systems, isSuccess } = useGetSystemsQuery(); //= selectAllSystems();
const { data: siteStats, isSuccess: siteStatsSuccess } = useGetSiteStatsQuery();
function useInterval(callback, delay) {
const savedCallback = useRef();
@@ -255,7 +519,7 @@ const Main = (props) => {
useInterval(() => { advanceSystem() }, 3000)
//https://stackoverflow.com/questions/36559661/how-can-i-dispatch-from-child-components-in-react-redux
//https://stackoverflow.com/questions/42597602/react-onclick-pass-event-with-parameter
@@ -316,6 +580,31 @@ const Main = (props) => {
</Grid>
</Segment>
<Segment style={{ padding: '0em' }} vertical>
<Grid columns='equal' stackable>
<Grid.Row textAlign='center'>
<Grid.Column style={{ paddingBottom: '5em', paddingTop: '5em' }}>
<SiteStatsContainer />
</Grid.Column>
<Grid.Column style={{ paddingBottom: '5em', paddingTop: '5em' }}>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', height: '100%' }}>
<Statistic>
<Statistic.Value>
{siteStats?.activeSystems || 0} <Icon name='volume up' size='small' />
</Statistic.Value>
<Statistic.Label>Active Systems</Statistic.Label>
</Statistic>
<Statistic>
<Statistic.Value>
{siteStats?.totalClients || 0} <Icon name='headphones' size='small' />
</Statistic.Value>
<Statistic.Label>People Listening</Statistic.Label>
</Statistic>
</div>
</Grid.Column>
</Grid.Row>
</Grid>
</Segment>
<Segment style={{ padding: '0em' }} vertical>

View File

@@ -40,6 +40,13 @@ export const apiSlice = createApi({
return responseData;
}
}),
getSiteStats: builder.query({
query: () => ({ url: '/stats' }),
transformResponse: responseData => {
// You can add any data transformation here if needed
return responseData;
}
}),
getTalkgroups: builder.query({
// The URL for the request is '/fakeApi/posts'
query: (shortName) => ({ url: `/${shortName}/talkgroups` })
@@ -69,4 +76,4 @@ export const apiSlice = createApi({
})
// Export the auto-generated hook for the `getPosts` query endpoint
export const { useGetGroupsQuery, useGetSystemsQuery, useGetTalkgroupsQuery, useGetStatsQuery, useGetEventsQuery, useGetEventQuery, useAddNewEventMutation, useContactSystemMutation } = apiSlice
export const { useGetGroupsQuery, useGetSystemsQuery, useGetTalkgroupsQuery, useGetStatsQuery, useGetSiteStatsQuery, useGetEventsQuery, useGetEventQuery, useAddNewEventMutation, useContactSystemMutation } = apiSlice