mirror of
https://github.com/openmhz/trunk-server.git
synced 2026-11-14 17:58:02 -05:00
new graph on main page
This commit is contained in:
@@ -95,6 +95,10 @@ function addSystemClients(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addTotalClients(req, res, next) {
|
||||||
|
req.totalClients = clients.length;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*------ CALLS ----------*/
|
/*------ CALLS ----------*/
|
||||||
@@ -135,7 +139,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', sys_stats.siteStats)
|
app.get('/stats', addTotalClients, sys_stats.siteStats)
|
||||||
|
|
||||||
|
|
||||||
function get_clients(req, res) {
|
function get_clients(req, res) {
|
||||||
|
|||||||
@@ -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
|
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 uploadsPerMin = new Array(spots).fill(0);
|
||||||
let activeSystems = 0;
|
let activeSystems = 0;
|
||||||
|
let totalClients = 0;
|
||||||
function updateUploadsPerMin(totalUploads) {
|
function updateUploadsPerMin(totalUploads) {
|
||||||
uploadsPerMin.push(totalUploads / timePeriod);
|
uploadsPerMin.push(totalUploads / timePeriod);
|
||||||
uploadsPerMin.shift();
|
uploadsPerMin.shift();
|
||||||
@@ -42,6 +43,10 @@ async function updateActiveSystems() {
|
|||||||
await item.save();
|
await item.save();
|
||||||
};
|
};
|
||||||
updateUploadsPerMin(siteTotal);
|
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);
|
||||||
}
|
}
|
||||||
@@ -81,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();
|
||||||
@@ -334,10 +355,12 @@ exports.shiftStats = async function () {
|
|||||||
console.log("Finished Updating Active Systems at: " + new Date());
|
console.log("Finished Updating Active Systems at: " + new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.siteStats = function() {
|
|
||||||
|
exports.siteStats = function(req, res) {
|
||||||
const siteStats ={
|
const siteStats ={
|
||||||
uploadsPerMin,
|
uploadsPerMin,
|
||||||
activeSystems
|
activeSystems,
|
||||||
|
totalClients: req.totalClients
|
||||||
}
|
}
|
||||||
res.contentType('json');
|
res.contentType('json');
|
||||||
res.send(JSON.stringify(siteStats));
|
res.send(JSON.stringify(siteStats));
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 { 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 */
|
/* 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,
|
||||||
@@ -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) {
|
const BetterSiteStatsChart = ({siteStats}) => {
|
||||||
return <Loader size='large'>Loading</Loader>;
|
//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 width = 500;
|
||||||
const height = 250;
|
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 getMouseData = (event) => {
|
||||||
const yMax = height - margin.top - margin.bottom;
|
const coords = localPoint(event.target.ownerSVGElement, event);
|
||||||
|
const { x } = coords;
|
||||||
|
|
||||||
const now = new Date();
|
const x0 = dateScale.invert(x);
|
||||||
const MS_PER_MINUTE = 60000;
|
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) => ({
|
return { data: d, coords }
|
||||||
y: stat,
|
}
|
||||||
x: new Date(now - index * 15 * MS_PER_MINUTE)
|
|
||||||
})).reverse();
|
|
||||||
|
|
||||||
const xScale = scaleTime({
|
const handleMouseOver = (event, datum) => {
|
||||||
range: [0, xMax],
|
const { coords, data } = getMouseData(event);
|
||||||
domain: [data[0].x, data[data.length - 1].x],
|
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({
|
const calls = useMemo(
|
||||||
range: [yMax, 0],
|
() => {
|
||||||
domain: [0, Math.max(...data.map(d => d.y))],
|
let callTotals = [];
|
||||||
nice: true,
|
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 (
|
return (
|
||||||
<svg width={width} height={height}>
|
// Set `ref={containerRef}` on the element corresponding to the coordinate system that
|
||||||
<Group left={margin.left} top={margin.top}>
|
// `left/top` (passed to `TooltipInPortal`) are relative to.
|
||||||
<LinePath
|
|
||||||
data={data}
|
<div style={{position: 'relative'}}>
|
||||||
x={d => xScale(d.x)}
|
<svg ref={containerRef} width={width} height={height} >
|
||||||
y={d => yScale(d.y)}
|
|
||||||
stroke="#1a85ff"
|
<rect
|
||||||
strokeWidth={2}
|
x={margin.left}
|
||||||
curve={curveStep}
|
y={0}
|
||||||
/>
|
width={width-margin.left-margin.right}
|
||||||
<AxisBottom
|
height={height}
|
||||||
scale={xScale}
|
fill="url(#area-background-gradient)"
|
||||||
top={yMax}
|
rx={14}
|
||||||
numTicks={5}
|
/>
|
||||||
tickFormat={timeFormat("%H:%M")}
|
|
||||||
/>
|
<LinearGradient id="area-background-gradient" from={background} to={background2} />
|
||||||
<AxisLeft scale={yScale} />
|
<LinearGradient id="area-gradient" from={accentColor} to={accentColor} toOpacity={0.1} />
|
||||||
</Group>
|
<GridRows
|
||||||
</svg>
|
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 SiteStatsContainer = () => {
|
||||||
const { data: siteStats, isSuccess: siteStatsSuccess } = useGetStatsQuery();
|
const { data: siteStats, isSuccess: siteStatsSuccess } = useGetSiteStatsQuery();
|
||||||
|
|
||||||
if (!siteStatsSuccess) {
|
if (!siteStatsSuccess) {
|
||||||
return <Loader size='large'>Loading Site Stats</Loader>;
|
return <Loader size='large'>Loading Site Stats</Loader>;
|
||||||
@@ -287,7 +468,8 @@ const SiteStatsContainer = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2>Site Activity</h2>
|
<h2>Site Activity</h2>
|
||||||
<SiteStatsChart siteStats={siteStats.callTotals} />
|
<BetterSiteStatsChart siteStats={siteStats.uploadsPerMin} />
|
||||||
|
Calls per Minute
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -300,7 +482,7 @@ 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 } = useGetStatsQuery();
|
const { data: siteStats, isSuccess: siteStatsSuccess } = useGetSiteStatsQuery();
|
||||||
|
|
||||||
|
|
||||||
function useInterval(callback, delay) {
|
function useInterval(callback, delay) {
|
||||||
@@ -405,12 +587,20 @@ const Main = (props) => {
|
|||||||
<SiteStatsContainer />
|
<SiteStatsContainer />
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
<Grid.Column style={{ paddingBottom: '5em', paddingTop: '5em' }}>
|
<Grid.Column style={{ paddingBottom: '5em', paddingTop: '5em' }}>
|
||||||
<Statistic>
|
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', height: '100%' }}>
|
||||||
<Statistic.Value>
|
<Statistic>
|
||||||
{siteStats?.activeSystems || 0}
|
<Statistic.Value>
|
||||||
</Statistic.Value>
|
{siteStats?.activeSystems || 0} <Icon name='volume up' size='small' />
|
||||||
<Statistic.Label>Active Systems</Statistic.Label>
|
</Statistic.Value>
|
||||||
</Statistic>
|
<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.Column>
|
||||||
</Grid.Row>
|
</Grid.Row>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -76,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
|
||||||
|
|||||||
Reference in New Issue
Block a user