From 0683b93e0cea692db3fd6f5af9e01ca7945628c5 Mon Sep 17 00:00:00 2001 From: Luke Berndt Date: Mon, 2 Sep 2024 21:57:56 -0400 Subject: [PATCH] new graph on main page --- backend/index.js | 6 +- backend/sys_stats.js | 27 +- frontend/src/Call/BetterActivityChart.js | 2 +- frontend/src/Main/Main.js | 314 ++++++++++++++++++----- frontend/src/features/api/apiSlice.js | 2 +- 5 files changed, 284 insertions(+), 67 deletions(-) diff --git a/backend/index.js b/backend/index.js index 1ef08f8..aa47ab9 100644 --- a/backend/index.js +++ b/backend/index.js @@ -95,6 +95,10 @@ function addSystemClients(req, res, next) { next(); } +function addTotalClients(req, res, next) { + req.totalClients = clients.length; + next(); +} /*------ CALLS ----------*/ @@ -135,7 +139,7 @@ app.get('/events/:id', events.getEvent); /*------ STATS ----------*/ app.get('/:shortName/stats', stats.get_stats); -app.get('/stats', sys_stats.siteStats) +app.get('/stats', addTotalClients, sys_stats.siteStats) function get_clients(req, res) { diff --git a/backend/sys_stats.js b/backend/sys_stats.js index 490ef1a..d312a24 100644 --- a/backend/sys_stats.js +++ b/backend/sys_stats.js @@ -11,6 +11,7 @@ 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(); @@ -42,6 +43,10 @@ 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); } @@ -81,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(); @@ -334,10 +355,12 @@ exports.shiftStats = async function () { console.log("Finished Updating Active Systems at: " + new Date()); } -exports.siteStats = function() { + +exports.siteStats = function(req, res) { const siteStats ={ uploadsPerMin, - activeSystems + activeSystems, + totalClients: req.totalClients } res.contentType('json'); res.send(JSON.stringify(siteStats)); diff --git a/frontend/src/Call/BetterActivityChart.js b/frontend/src/Call/BetterActivityChart.js index a0a007e..63cc732 100644 --- a/frontend/src/Call/BetterActivityChart.js +++ b/frontend/src/Call/BetterActivityChart.js @@ -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) => { diff --git a/frontend/src/Main/Main.js b/frontend/src/Main/Main.js index 64269ad..bf021b7 100644 --- a/frontend/src/Main/Main.js +++ b/frontend/src/Main/Main.js @@ -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 { useGetStatsQuery, 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) => { ) } + + /* MobileContainer.propTypes = { children: PropTypes.node, @@ -214,71 +228,238 @@ const ResponsiveContainer = ({ children }) => ( ) -import React from "react"; -import { Group } from '@visx/group'; -import { curveStep } from '@visx/curve'; -import { LinePath } from '@visx/shape'; -import { scaleLinear, scaleTime } from '@visx/scale'; -import { timeParse, timeFormat } from 'd3-time-format'; -import { AxisBottom, AxisLeft } from '@visx/axis'; -import { Loader } from "semantic-ui-react"; -const SiteStatsChart = ({ siteStats }) => { - if (!siteStats) { - return Loading; - } + +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 margin = { top: 20, bottom: 40, left: 40, right: 20 }; + 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 xMax = width - margin.left - margin.right; - const yMax = height - margin.top - margin.bottom; + const getMouseData = (event) => { + const coords = localPoint(event.target.ownerSVGElement, event); + const { x } = coords; - const now = new Date(); - const MS_PER_MINUTE = 60000; + 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; + } - const data = siteStats.map((stat, index) => ({ - y: stat, - x: new Date(now - index * 15 * MS_PER_MINUTE) - })).reverse(); + return { data: d, coords } +} - const xScale = scaleTime({ - range: [0, xMax], - domain: [data[0].x, data[data.length - 1].x], - }); +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 yScale = scaleLinear({ - range: [yMax, 0], - domain: [0, Math.max(...data.map(d => d.y))], - nice: true, - }); + 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 ( - - - xScale(d.x)} - y={d => yScale(d.y)} - stroke="#1a85ff" - strokeWidth={2} - curve={curveStep} - /> - - - - - ); + // Set `ref={containerRef}` on the element corresponding to the coordinate system that + // `left/top` (passed to `TooltipInPortal`) are relative to. + +
+ + + + + + + + + dateScale(getDate(d)) ?? 0} + y={(d) => callActivityScale(getCallActivity(d)) ?? 0} + yScale={callActivityScale} + strokeWidth={1} + stroke="url(#area-gradient)" + fill="url(#area-gradient)" + curve={curveMonotoneX} + + /> + + + {tooltipData && ( + + + + + + )} + + {tooltipOpen && ( +
+ + {`${getCallActivity(tooltipData)}`} + + + {formatDate(getDate(tooltipData))} + +
+ )} +
+ + ) }; const SiteStatsContainer = () => { - const { data: siteStats, isSuccess: siteStatsSuccess } = useGetStatsQuery(); + const { data: siteStats, isSuccess: siteStatsSuccess } = useGetSiteStatsQuery(); if (!siteStatsSuccess) { return Loading Site Stats; @@ -287,7 +468,8 @@ const SiteStatsContainer = () => { return (

Site Activity

- + + Calls per Minute
); }; @@ -300,7 +482,7 @@ const Main = (props) => { const [currentSystem, setCurrentSystem] = useState(0); const navigate = useNavigate(); const { data: systems, isSuccess } = useGetSystemsQuery(); //= selectAllSystems(); - const { data: siteStats, isSuccess: siteStatsSuccess } = useGetStatsQuery(); + const { data: siteStats, isSuccess: siteStatsSuccess } = useGetSiteStatsQuery(); function useInterval(callback, delay) { @@ -405,12 +587,20 @@ const Main = (props) => { - - - {siteStats?.activeSystems || 0} - - Active Systems - +
+ + + {siteStats?.activeSystems || 0} + + Active Systems + + + + {siteStats?.totalClients || 0} + + People Listening + +
diff --git a/frontend/src/features/api/apiSlice.js b/frontend/src/features/api/apiSlice.js index 03239be..bb054f5 100644 --- a/frontend/src/features/api/apiSlice.js +++ b/frontend/src/features/api/apiSlice.js @@ -76,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