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 **/ /** Important **/
/** You should not be committing this file to GitHub **/ /** You should not be committing this file to GitHub **/
/** Repeat: DO! NOT! COMMIT! THIS! FILE! TO! YOUR! REPO! **/ /** 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'; const mongo_host = typeof process.env['MONGO_HOST'] !== 'undefined' ? process.env['MONGO_HOST'] : 'mongo';
var port = process.env['MONGO_NODE_DRIVER_PORT'] != null ? process.env['MONGO_NODE_DRIVER_PORT'] : 27017; const mongo_port = typeof process.env['MONGO_PORT'] !== 'undefined' ? process.env['MONGO_PORT'] : 27017;
var mongoUrl = 'mongodb://' + host + ':' + port + '/scanner'; 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 = { const secrets = {
db: mongoUrl, db: mongoUrl,

View File

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

View File

@@ -1,11 +1,19 @@
/** Important **/ /** Important **/
/** You should not be committing this file to GitHub **/ /** You should not be committing this file to GitHub **/
/** Repeat: DO! NOT! COMMIT! THIS! FILE! TO! YOUR! REPO! **/ /** 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'; const mongo_host = typeof process.env['MONGO_HOST'] !== 'undefined' ? process.env['MONGO_HOST'] : 'mongo';
var port = process.env['MONGO_NODE_DRIVER_PORT'] != null ? process.env['MONGO_NODE_DRIVER_PORT'] : 27017; const mongo_port = typeof process.env['MONGO_PORT'] !== 'undefined' ? process.env['MONGO_PORT'] : 27017;
var mongoUrl = 'mongodb://' + host + ':' + port + '/scanner'; 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 = { module.exports = secrets = {
db: mongoUrl, db: mongoUrl,

View File

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

View File

@@ -399,6 +399,7 @@ const System = () => {
<em> <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 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/> 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 /> An example header line is: <br />
<code>Decimal,Alpha Tag,Description</code><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> <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 opentelemetry = require('@opentelemetry/sdk-node');
const { MongoDBInstrumentation } = require('@opentelemetry/instrumentation-mongodb'); const { MongoDBInstrumentation } = require('@opentelemetry/instrumentation-mongodb');
const { TraceIdRatioBasedSampler } = require('@opentelemetry/sdk-trace-node'); const { TraceIdRatioBasedSampler } = require('@opentelemetry/sdk-trace-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node'); const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { diag, DiagConsoleLogger, DiagLogLevel } = require('@opentelemetry/api'); const { diag, DiagConsoleLogger, DiagLogLevel } = require('@opentelemetry/api');
const {
Detector,
DetectorSync,
IResource,
ResourceDetectionConfig,
envDetectorSync,
hostDetectorSync,
processDetectorSync,
} = require("@opentelemetry/resources")
// const { // const {
// dockerCGroupV1Detector, // dockerCGroupV1Detector,
// } = require('@opentelemetry/resource-detector-docker'); // } = 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); 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 samplePercentage = 0.05;
const sdk = new opentelemetry.NodeSDK({ const sdk = new opentelemetry.NodeSDK({
@@ -37,6 +58,11 @@ const sdk = new opentelemetry.NodeSDK({
}, },
}), }),
], ],
resourceDetectors: [
awaitAttributes(envDetectorSync),
awaitAttributes(processDetectorSync),
awaitAttributes(hostDetectorSync),
],
//resourceDetectors: [dockerCGroupV1Detector], //resourceDetectors: [dockerCGroupV1Detector],
}); });

View File

@@ -14,6 +14,7 @@ var fs = require('fs');
const { PutObjectCommand, S3Client } = require("@aws-sdk/client-s3"); const { PutObjectCommand, S3Client } = require("@aws-sdk/client-s3");
const { fromIni } = require("@aws-sdk/credential-providers"); 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_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'; 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) => { inputs.forEach((value, i) => {
concat = concat + "file '" + value + "'\n"; concat = concat + "file '" + value + "'\n";
}); });
const concatFile = tmpEventFolder + "/FILES.txt" try {
fs.writeFileSync(concatFile, concat, "utf8"); const concatFile = tmpEventFolder + "/FILES.txt"
// https://video.stackexchange.com/questions/21315/concatenating-split-media-files-using-concat-protocol fs.writeFileSync(concatFile, concat, "utf8");
//ffmpeg -f concat -safe 0 -i mylist.txt -c copy output // https://video.stackexchange.com/questions/21315/concatenating-split-media-files-using-concat-protocol
command.push("-hide_banner"); //ffmpeg -f concat -safe 0 -i mylist.txt -c copy output
command.push("-loglevel"); command.push("-hide_banner");
command.push("error"); command.push("-loglevel");
command.push("-f"); command.push("error");
command.push("concat"); command.push("-f");
command.push("-safe"); command.push("concat");
command.push("0"); command.push("-safe");
command.push("-i"); command.push("0");
command.push(concatFile); command.push("-i");
command.push("-acodec"); command.push(concatFile);
command.push("aac"); command.push("-acodec");
command.push(tmpFile); command.push("aac");
await ffmpeg(command); command.push(tmpFile);
await ffmpeg(command);
const chapters = createChapters(event); const chapters = createChapters(event);
const chaptersFile = tmpEventFolder + "/CHAPTERS.txt" const chaptersFile = tmpEventFolder + "/CHAPTERS.txt"
fs.writeFileSync(chaptersFile, chapters, "utf8"); fs.writeFileSync(chaptersFile, chapters, "utf8");
command = []; command = [];
command.push("-hide_banner"); command.push("-hide_banner");
command.push("-loglevel"); command.push("-loglevel");
command.push("error"); command.push("error");
command.push("-i"); command.push("-i");
command.push(tmpFile) command.push(tmpFile)
command.push("-i"); command.push("-i");
command.push(chaptersFile) command.push(chaptersFile)
command.push("-map_metadata") command.push("-map_metadata")
command.push("1") command.push("1")
command.push("-codec"); command.push("-codec");
command.push("copy"); command.push("copy");
command.push(podcastFile) command.push(podcastFile)
//console.log("Running command: " + command); //console.log("Running command: " + command);
await ffmpeg(command); await ffmpeg(command);
} catch (e) {
console.error("Error creating podcast " + e);
}
} }
const cleanupEvent = (folder, filename) => { const cleanupEvent = (folder, filename) => {
@@ -382,11 +387,12 @@ exports.addNewEvent = async function (req, res, next) {
const savedEvent = await event.save(); const savedEvent = await event.save();
//console.log("Event ID " + savedEvent._id); //console.log("Event ID " + savedEvent._id);
const url = "/events/" + event._id; const url = "/events/" + event._id;
res.contentType('json');
res.send(JSON.stringify({ url: url }));
await packageEvent(savedEvent._id); await packageEvent(savedEvent._id);
res.contentType('json');
res.send(JSON.stringify({ url: url }));
} }
catch (err) { catch (err) {
console.error("Error creating event" + 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 Talkgroup = require("../models/talkgroup");
const Group = require("../models/group");
exports.get_talkgroups = async function (req, res) { let groups = {};
const tg_results = await Talkgroup.find({ 'shortName': req.params.shortName.toLowerCase()}).catch(err => { let talkgroups = {};
async function load_talkgroups(shortName) {
const tg_results = await Talkgroup.find({ 'shortName': shortName }).catch(err => {
console.error(err); console.error(err);
res.status(500); res.status(500);
res.json({ success: false, message: err }); res.json({ success: false, message: err });
return; return;
}); });
var talkgroups = {}; let temp = {}
for (var tg in tg_results) { 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} 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; 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.contentType('json');
res.send(JSON.stringify({ res.send(JSON.stringify({
talkgroups: talkgroups 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) { exports.upload = async function (req, res, next) {
const tracer = trace.getTracer("upload-service"); 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 () => { return context.with(context.active(), async () => {
const parentSpan = trace.getActiveSpan(context.active()); const parentSpan = trace.getActiveSpan(context.active());
await tracer.startActiveSpan('upload_handler', { parent: parentSpan }, async (span) => { await tracer.startActiveSpan('upload_handler', { parent: parentSpan }, async (span) => {
@@ -108,7 +115,7 @@ exports.upload = async function (req, res, next) {
res.status(200).end(); res.status(200).end();
return; return;
} }
validateSystemTime = Date.now();
if (item.ignoreUnknownTalkgroup) { if (item.ignoreUnknownTalkgroup) {
const talkgroupExists = await Talkgroup.exists({ const talkgroupExists = await Talkgroup.exists({
shortName, shortName,
@@ -129,7 +136,7 @@ exports.upload = async function (req, res, next) {
res.status(200).end(); res.status(200).end();
const local_path = `/${shortName}/${time.getFullYear()}/${time.getMonth() + 1}/${time.getDate()}/`; 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 url = `${s3_public_url}/${object_key}`;
const call = new Call({ const call = new Call({
@@ -151,10 +158,11 @@ exports.upload = async function (req, res, next) {
}); });
let fileContent; let fileContent;
await tracer.startActiveSpan('upload_to_s3', { parent: trace.getActiveSpan(context.active()) }, async (uploadSpan) => { await tracer.startActiveSpan('upload_to_s3', { parent: trace.getActiveSpan(context.active()) }, async (uploadSpan) => {
try { try {
fileContent = fs.readFileSync(req.file.path); fileContent = fs.readFileSync(req.file.path);
readFileTime = Date.now();
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: s3_bucket, Bucket: s3_bucket,
Key: object_key, Key: object_key,
@@ -177,7 +185,7 @@ exports.upload = async function (req, res, next) {
uploadSpan.end(); uploadSpan.end();
} }
}); });
uploadFileTime = Date.now();
await tracer.startActiveSpan('save_call', { parent: trace.getActiveSpan(context.active()) }, async (saveSpan) => { await tracer.startActiveSpan('save_call', { parent: trace.getActiveSpan(context.active()) }, async (saveSpan) => {
try { try {
await call.save(); await call.save();
@@ -193,7 +201,9 @@ exports.upload = async function (req, res, next) {
} }
}); });
saveCallTime = Date.now();
sysStats.addCall(call.toObject()); sysStats.addCall(call.toObject());
statsTime = Date.now();
if (call.len >= 1) { if (call.len >= 1) {
req.call = call.toObject(); req.call = call.toObject();
@@ -211,7 +221,8 @@ exports.upload = async function (req, res, next) {
cleanupSpan.end(); 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) { } catch (error) {
console.error("Error processing call upload: " + error); console.error("Error processing call upload: " + error);
span.recordException(error); span.recordException(error);
@@ -222,6 +233,7 @@ exports.upload = async function (req, res, next) {
} finally { } finally {
span.end(); span.end();
} }
}); });
}); });
}; };

View File

@@ -6,8 +6,9 @@ var calls = require("./controllers/calls");
var uploads = require("./controllers/uploads"); var uploads = require("./controllers/uploads");
var systems = require("./controllers/systems"); var systems = require("./controllers/systems");
var talkgroups = require("./controllers/talkgroups"); var talkgroups = require("./controllers/talkgroups");
var groups = require("./controllers/groups");
var stats = require("./controllers/stats"); var stats = require("./controllers/stats");
var sys_stats = require("./sys_stats");
var events = require("./controllers/events"); var events = require("./controllers/events");
var config = require('./config/config.json'); var config = require('./config/config.json');
let db = require('./db') 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'; const mongo_host = typeof process.env['MONGO_HOST'] !== 'undefined' ? process.env['MONGO_HOST'] : 'mongo';
var port = process.env['MONGO_NODE_DRIVER_PORT'] != null ? process.env['MONGO_NODE_DRIVER_PORT'] : 27017; const mongo_port = typeof process.env['MONGO_PORT'] !== 'undefined' ? process.env['MONGO_PORT'] : 27017;
var mongoUrl = 'mongodb://' + host + ':' + port + '/scanner'; 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 () => { const connect = async () => {
@@ -60,7 +71,7 @@ const connect = async () => {
console.log('Mongoose is disconnecting'); 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"); console.log("All Done");
} }
connect(); connect();
@@ -94,6 +105,11 @@ function addSystemClients(req, res, next) {
next(); next();
} }
function addTotalClients(req, res, next) {
req.totalClients = Object.keys(clients).length;
next();
}
/*------ CALLS ----------*/ /*------ CALLS ----------*/
@@ -124,7 +140,7 @@ app.post('/:shortName/authorize', systems.authorize_system);
app.get('/:shortName/talkgroups', talkgroups.get_talkgroups); app.get('/:shortName/talkgroups', talkgroups.get_talkgroups);
/*------ GROUPS ----------*/ /*------ GROUPS ----------*/
app.get('/:shortName/groups', groups.get_groups); app.get('/:shortName/groups', talkgroups.get_groups);
/*------ EVENTS ----------*/ /*------ EVENTS ----------*/
@@ -134,6 +150,7 @@ app.get('/events/:id', events.getEvent);
/*------ STATS ----------*/ /*------ STATS ----------*/
app.get('/:shortName/stats', stats.get_stats); app.get('/:shortName/stats', stats.get_stats);
app.get('/stats', addTotalClients, sys_stats.siteStats)
function get_clients(req, res) { 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/client-s3": "^3.309.0",
"@aws-sdk/credential-provider-process": "^3.306.0", "@aws-sdk/credential-provider-process": "^3.306.0",
"@aws-sdk/credential-providers": "^3.309.0", "@aws-sdk/credential-providers": "^3.309.0",
"@honeycombio/opentelemetry-node": "^0.7.2", "@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/api": "^1.7.0", "@opentelemetry/resources": "^1.26.0",
"@opentelemetry/auto-instrumentations-node": "^0.41.1",
"@opentelemetry/instrumentation-mongodb": "^0.46.0",
"@opentelemetry/resource-detector-docker": "^0.1.2",
"@smithy/node-http-handler": "^3.1.4", "@smithy/node-http-handler": "^3.1.4",
"adm-zip": "^0.5.10", "adm-zip": "^0.5.10",
"axios": "1.3.5", "axios": "1.3.5",

View File

@@ -9,12 +9,21 @@ var uploadErrors = {};
let decodeErrorsFreq = {}; let decodeErrorsFreq = {};
const timePeriod = 15; // in minutes const timePeriod = 15; // in minutes
var spots = (24 * 60) / timePeriod; // the number of spots needed to keep track of 24 hours of stats 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() { async function updateActiveSystems() {
// Go through all of the Systems // Go through all of the Systems
let siteTotal = 0; let siteTotal = 0;
let activeSystems = 0; activeSystems = 0;
for await (let item of System.find()) { for await (let item of System.find()) {
// go through all the systems // go through all the systems
@@ -33,6 +42,11 @@ async function updateActiveSystems() {
await item.save(); 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("Site average uploads per minute: " + siteTotal / timePeriod);
console.log("Active Systems: " + activeSystems); console.log("Active Systems: " + activeSystems);
} }
@@ -72,6 +86,22 @@ exports.initStats = async function () {
console.error("Decode Errors File not found: " + decodeErrorsFreqPath); 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()) { for await (const item of SystemStat.find()) {
const obj = item.toObject(); const obj = item.toObject();
@@ -324,6 +354,18 @@ exports.shiftStats = async function () {
updateActiveSystems(); updateActiveSystems();
console.log("Finished Updating Active Systems at: " + new Date()); 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) { exports.callTotals = function (shortName) {
return callTotals[shortName]; return callTotals[shortName];
} }

View File

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

View File

@@ -27,7 +27,7 @@ const tooltipStyles = {
}; };
// util // util
const formatDate = timeFormat("%b %d, %H:%m"); const formatDate = timeFormat("%b %d, %H:%MM");
// accessors // accessors
const getDate = (d) => { 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 { Link, useNavigate } from 'react-router-dom'
import SystemCard from "../System/SystemCard"; import SystemCard from "../System/SystemCard";
import SupportModal from "../Common/SupportModal"; import SupportModal from "../Common/SupportModal";
@@ -16,12 +16,24 @@ import {
Menu, Menu,
Segment, Segment,
Sidebar, Sidebar,
Loader,
Statistic,
Transition, Transition,
} from 'semantic-ui-react' } 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 { 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 */ /* 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> </Media>
) )
} }
/* /*
MobileContainer.propTypes = { MobileContainer.propTypes = {
children: PropTypes.node, children: PropTypes.node,
@@ -213,6 +227,254 @@ const ResponsiveContainer = ({ children }) => (
</div> </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) => { const Main = (props) => {
@@ -220,6 +482,8 @@ const Main = (props) => {
const [currentSystem, setCurrentSystem] = useState(0); const [currentSystem, setCurrentSystem] = useState(0);
const navigate = useNavigate(); const navigate = useNavigate();
const { data: systems, isSuccess } = useGetSystemsQuery(); //= selectAllSystems(); const { data: systems, isSuccess } = useGetSystemsQuery(); //= selectAllSystems();
const { data: siteStats, isSuccess: siteStatsSuccess } = useGetSiteStatsQuery();
function useInterval(callback, delay) { function useInterval(callback, delay) {
const savedCallback = useRef(); const savedCallback = useRef();
@@ -316,6 +580,31 @@ const Main = (props) => {
</Grid> </Grid>
</Segment> </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> <Segment style={{ padding: '0em' }} vertical>

View File

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