Merge pull request #215 from gnmyt/features/statistics

📈 Neue Statistik-Seite
This commit is contained in:
Mathias Wagner
2023-02-24 02:57:09 +01:00
committed by GitHub
40 changed files with 1037 additions and 40 deletions

View File

@@ -12,11 +12,13 @@
"@fortawesome/free-solid-svg-icons": "^6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@vitejs/plugin-react": "^3.1.0",
"chart.js": "^4.2.1",
"cron-parser": "^4.7.1",
"i18next": "^22.4.10",
"i18next-browser-languagedetector": "^7.0.1",
"i18next-http-backend": "^2.1.1",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^12.1.5",
"sass": "^1.58.3",
@@ -2020,6 +2022,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2497,6 +2504,17 @@
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.2.1.tgz",
"integrity": "sha512-6YbpQ0nt3NovAgOzbkSSeeAQu/3za1319dPUQTXn9WcOpywM8rGKxJHrhS8V8xEkAlk8YhEfjbuAPfUyp6jIsw==",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": "^7.0.0"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -3810,6 +3828,15 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz",
"integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@@ -6063,6 +6090,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"@kurkle/color": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
},
"@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -6405,6 +6437,14 @@
}
}
},
"chart.js": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.2.1.tgz",
"integrity": "sha512-6YbpQ0nt3NovAgOzbkSSeeAQu/3za1319dPUQTXn9WcOpywM8rGKxJHrhS8V8xEkAlk8YhEfjbuAPfUyp6jIsw==",
"requires": {
"@kurkle/color": "^0.3.0"
}
},
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -7340,6 +7380,12 @@
"loose-envify": "^1.1.0"
}
},
"react-chartjs-2": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz",
"integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==",
"requires": {}
},
"react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",

View File

@@ -11,13 +11,15 @@
"@fortawesome/free-solid-svg-icons": "^6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@vitejs/plugin-react": "^3.1.0",
"chart.js": "^4.2.1",
"cron-parser": "^4.7.1",
"i18next": "^22.4.10",
"i18next-browser-languagedetector": "^7.0.1",
"i18next-http-backend": "^2.1.1",
"react-i18next": "^12.1.5",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^12.1.5",
"sass": "^1.58.3",
"vite": "^4.1.2",
"vite-plugin-pwa": "^0.14.4"

View File

@@ -42,6 +42,7 @@
"resume_tests": "Tests fortsetzen",
"healthchecks": "Healthchecks",
"language": "Sprache ändern",
"view": "Ansicht wechseln",
"info": "Infos zum Projekt"
},
"options": {
@@ -98,7 +99,8 @@
"healthchecks": "HealthChecks Integration",
"healthchecks_url": "HealthChecks Server URL",
"healthchecks_activated": "Die Healthchecks wurden deaktiviert",
"language": "Sprache ändern"
"language": "Sprache ändern",
"view_title": "Ansicht wechseln"
},
"header": {
"title": "Netzwerkanalyse",
@@ -107,7 +109,11 @@
"new_update": "Update verfügbar",
"paused": "Speedtests sind aktuell pausiert. Bitte setze sie fort, wenn du einen machen möchtest.",
"running": "Es läuft bereits ein Speedtest. Bitte warte noch einen Moment.",
"admin_login": "Admin-Login"
"admin_login": "Admin-Login",
"beta": {
"title": "Beta-Version",
"description": "Diese Funktion befindet sich noch in der Beta-Phase. Wenn du Fehler findest, melde sie bitte <Link>hier</Link>."
}
},
"latest": {
"ping": "Ping",
@@ -171,6 +177,17 @@
"custom": "Benutzerdefiniert",
"average": "Durchschnitt",
"auto": "Automatisiert"
},
"views": {
"list": "Test-Übersicht",
"statistic": "Test-Statistik"
},
"overview": {
"title": "Test-Übersicht der letzten {{amount}}",
"1": "24 Stunden",
"2": "2 Tage",
"3": "7 Tage",
"4": "30 Tage"
}
},
"errors": {
@@ -181,6 +198,42 @@
"no_route": "Der Test konnte nicht durchgeführt werden, da keine Route zum Host existierte",
"connection_refused": "Der Test konnte nicht durchgeführt werden, da die Verbindung abgelehnt wurde",
"timed_out": "Die Internetverbindung war in der Zeit des Tests instabil",
"config": "Die Konfigurationsdatei konnte nicht geladen werden"
"config": "Die Konfigurationsdatei konnte nicht geladen werden",
"invalid_view": "Ungültige Ansicht"
},
"statistics": {
"overview": {
"total_title": "Insgesamte Tests",
"total_description": "Die Anzahl der ausgeführten Tests",
"failed_title": "Fehlgeschlagene Tests",
"failed_description": "Die Anzahl der fehlgeschlagenen Tests",
"average_title": "Durchschnittsdauer",
"average_description": "Die durchschnittliche Dauer der Tests"
},
"failed": {
"title": "Fehlgeschlagene Tests",
"success": "Erfolgreich",
"failed": "Fehlgeschlagen",
"label": "Tests"
},
"speed": {
"title": "Geschwindigkeit"
},
"manual": {
"title": "Manuelle Tests",
"yes": "Manuell erstellt",
"no": "Automatisch erstellt"
},
"duration": {
"title": "Testdauer",
"label": "Anzahl"
},
"values": {
"min": "Mindestens",
"max": "Höchstens",
"avg": "Durchschnitt",
"down": "Download-Werte",
"up": "Upload-Werte"
}
}
}

View File

@@ -42,6 +42,7 @@
"resume_tests": "Resume tests",
"healthchecks": "Healthchecks",
"language": "Change language",
"view": "Switch view",
"info": "About the project"
},
"options": {
@@ -98,7 +99,8 @@
"healthchecks": "HealthChecks Integration",
"healthchecks_url": "HealthChecks Server URL",
"healthchecks_activated": "The health checks have been disabled",
"language": "Change language"
"language": "Change language",
"view_title": "Switch view"
},
"header": {
"title": "Network Analysis",
@@ -107,7 +109,11 @@
"new_update": "Update available",
"paused": "Speedtests are currently paused. Please continue them if you want to do one.",
"running": "A speedtest is already running. Please wait a moment.",
"admin_login": "Admin Login"
"admin_login": "Admin Login",
"beta": {
"title": "Beta Version",
"description": "This feature is still in beta. If you find any bugs, please report them <Link>here</Link>."
}
},
"latest": {
"ping": "Ping",
@@ -171,6 +177,17 @@
"custom": "Custom",
"average": "Average",
"auto": "Automated"
},
"views": {
"list": "Test Overview",
"statistic": "Test statistics"
},
"overview": {
"title": "Test overview of the last {{amount}}",
"1": "24 hours",
"2": "2 days",
"3": "7 days",
"4": "30 days"
}
},
"errors": {
@@ -181,6 +198,42 @@
"no_route": "The test could not be performed because there was no route to the host",
"connection_refused": "The test could not be performed because the connection was rejected",
"timed_out": "Internet connection was unstable during the time of the test",
"config": "The configuration file could not be loaded"
"config": "The configuration file could not be loaded",
"invalid_view": "Invalid view"
},
"statistics": {
"overview": {
"total_title": "Total tests",
"total_description": "The number of executed tests",
"failed_title": "Failed tests",
"failed_description": "The number of failed tests",
"average_title": "Average duration",
"average_description": "The average duration of the tests"
},
"failed": {
"title": "Failed tests",
"success": "Successful",
"failed": "Failed",
"label": "Tests"
},
"speed": {
"title": "Speed"
},
"manual": {
"title": "Manual tests",
"yes": "Created manually",
"no": "Created automatically"
},
"duration": {
"title": "Test duration",
"label": "Amount"
},
"values": {
"min": "Minimum",
"max": "Maximum",
"avg": "Average",
"down": "Download values",
"up": "Upload values"
}
}
}
}

View File

@@ -5,11 +5,25 @@ import {SpeedtestProvider} from "./common/contexts/Speedtests";
import {ConfigProvider} from "./common/contexts/Config";
import {StatusProvider} from "./common/contexts/Status";
import {InputDialogProvider} from "@/common/contexts/InputDialog/InputDialog";
import {useState} from "react";
import {useContext, useState} from "react";
import i18n from './i18n';
import Loading from "@/pages/Loading";
import "@/common/styles/spinner.sass";
import Error from "@/pages/Error";
import {ViewContext, ViewProvider} from "@/common/contexts/View";
import Statistics from "@/pages/Statistics";
import {t} from "i18next";
const MainContent = () => {
const [view] = useContext(ViewContext);
return (
<main>
{view === 0 && <Home/>}
{view === 1 && <Statistics/>}
{view !== 0 && view !== 1 && <Error text={t("errors.invalid_view")} disableReload={true}/>}
</main>
);
}
const App = () => {
const [translationsLoaded, setTranslationsLoaded] = useState(false);
@@ -24,14 +38,14 @@ const App = () => {
{translationError && <Error text="Failed to load translations"/>}
{translationsLoaded && !translationError && <SpeedtestProvider>
<InputDialogProvider>
<ConfigProvider>
<StatusProvider>
<HeaderComponent/>
<main>
<Home/>
</main>
</StatusProvider>
</ConfigProvider>
<ViewProvider>
<ConfigProvider>
<StatusProvider>
<HeaderComponent/>
<MainContent/>
</StatusProvider>
</ConfigProvider>
</ViewProvider>
</InputDialogProvider>
</SpeedtestProvider>}
</>

View File

@@ -1,8 +1,7 @@
import React, {useContext, useEffect, useRef} from "react";
import React, {useContext, useEffect, useRef, useState} from "react";
import "./styles.sass";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {
faArrowDown, faArrowUp, faCalendarDays, faCircleNodes, faClock, faClose, faFileExport,
faArrowDown, faArrowUp, faCalendarDays, faCircleNodes, faClock, faClose, faFileExport, faChartSimple,
faGear, faGlobeEurope, faInfo, faKey, faPause, faPingPongPaddleBall, faPlay, faServer, faWandMagicSparkles
} from "@fortawesome/free-solid-svg-icons";
import {ConfigContext} from "@/common/contexts/Config";
@@ -11,9 +10,14 @@ import {InputDialogContext} from "@/common/contexts/InputDialog";
import {SpeedtestContext} from "@/common/contexts/Speedtests";
import {downloadRequest, jsonRequest, patchRequest, postRequest} from "@/common/utils/RequestUtil";
import {creditsInfo, healthChecksInfo, recommendationsInfo} from "@/common/components/Dropdown/utils/infos";
import {exportOptions, languageOptions, levelOptions, selectOptions, timeOptions} from "@/common/components/Dropdown/utils/options";
import {
exportOptions, languageOptions, levelOptions,
selectOptions, timeOptions
} from "@/common/components/Dropdown/utils/options";
import {parseCron, stringifyCron} from "@/common/components/Dropdown/utils/utils";
import {changeLanguage, t} from "i18next";
import ViewDialog from "@/common/components/ViewDialog";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
let icon;
@@ -36,6 +40,7 @@ function DropdownComponent() {
const [status, updateStatus] = useContext(StatusContext);
const updateTests = useContext(SpeedtestContext)[1];
const [setDialog] = useContext(InputDialogContext);
const [showViewDialog, setShowViewDialog] = useState(false);
const ref = useRef();
useEffect(() => {
@@ -225,6 +230,11 @@ function DropdownComponent() {
});
}
const updateView = () => {
toggleDropdown();
setShowViewDialog(true);
}
const showCredits = () => {
toggleDropdown();
setDialog({title: "MySpeed", description: creditsInfo(), buttonText: t("dialog.close")});
@@ -243,29 +253,36 @@ function DropdownComponent() {
{run: exportDialog, icon: faFileExport, text: t("dropdown.export")},
{run: togglePause, icon: status.paused ? faPlay : faPause, text: t("dropdown." + (status.paused ? "resume_tests" : "pause_tests"))},
{run: updateIntegration, icon: faCircleNodes, text: t("dropdown.healthchecks")},
{hr: true, key: 2},
{run: updateLanguage, icon: faGlobeEurope, text: t("dropdown.language"), allowView: true},
{run: updateView, icon: faChartSimple, allowView: true, text: t("dropdown.view")},
{run: showCredits, icon: faInfo, text: t("dropdown.info"), allowView: true}
];
return (
<div className="dropdown dropdown-invisible" id="dropdown" ref={ref}>
<div className="dropdown-content">
<h2>{t("dropdown.settings")}</h2>
<div className="dropdown-entries">
{options.map(entry => {
if (!config.viewMode || (config.viewMode && entry.allowView)) {
if (!entry.hr) {
return (<div className="dropdown-item" onClick={entry.run} key={entry.run}>
<FontAwesomeIcon icon={entry.icon}/>
<h3>{entry.text}</h3>
<>
{showViewDialog && <ViewDialog onClose={() => setShowViewDialog(false)}/>}
<div className="dropdown dropdown-invisible" id="dropdown" ref={ref}>
<div className="dropdown-content">
<h2>{t("dropdown.settings")}</h2>
<div className="dropdown-entries">
{options.map(entry => {
if (!config.viewMode || (config.viewMode && entry.allowView)) {
if (!entry.hr) {
return (<div className="dropdown-item" onClick={entry.run} key={entry.run}>
<FontAwesomeIcon icon={entry.icon}/>
<h3>{entry.text}</h3>
</div>);
} else return (<div className="center" key={entry.key}>
<hr className="dropdown-hr"/>
</div>);
} else return (<div className="center" key={entry.key}><hr className="dropdown-hr"/></div>);
}
})}
}
})}
</div>
</div>
</div>
</div>
)
</>
);
}
export default DropdownComponent;

View File

@@ -11,6 +11,9 @@ import {updateInfo} from "@/common/components/Header/utils/infos";
import {t} from "i18next";
import {ConfigContext} from "@/common/contexts/Config";
import {SpeedtestDialog} from "@/common/components/SpeedtestDialog";
import {ViewContext} from "@/common/contexts/View";
import {Trans} from "react-i18next";
import {PROJECT_URL} from "@/index";
function HeaderComponent() {
const [setDialog] = useContext(InputDialogContext);
@@ -20,6 +23,7 @@ function HeaderComponent() {
const updateTests = useContext(SpeedtestContext)[1];
const [config, reloadConfig, checkConfig] = useContext(ConfigContext);
const [updateAvailable, setUpdateAvailable] = useState("");
const [view] = useContext(ViewContext);
function switchDropdown() {
toggleDropdown(setIcon);
@@ -76,7 +80,11 @@ function HeaderComponent() {
<header>
<SpeedtestDialog isOpen={startedManually}/>
<div className="header-main">
<h2>{t("header.title")}</h2>
<h2>{t("header.title")} {view === 1 && <span className="beta-span" onClick={() => setDialog({
title: t("header.beta.title"),
description: <Trans components={{Link: <a href={PROJECT_URL+"/issues/new/choose"} target="_blank" />}}>header.beta.description</Trans>,
buttonText: t("dialog.okay")
})}>BETA</span>}</h2>
<div className="header-right">
{updateAvailable ?
<div><FontAwesomeIcon icon={faCircleArrowUp} className="header-icon icon-orange update-icon"

View File

@@ -18,6 +18,19 @@
.header-main h2
margin-left: 10%
display: flex
gap: 0.3rem
.beta-span
display: flex
cursor: pointer
align-items: center
justify-content: center
font-size: 14pt
background-color: $dark-gray
color: $green
padding: 0.1rem 0.4rem
border-radius: 0.7rem
.header-main div
margin-right: 10%

View File

@@ -0,0 +1,56 @@
import {DialogContext, DialogProvider} from "@/common/contexts/Dialog";
import {InputDialogContext} from "@/common/contexts/InputDialog";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faClose} from "@fortawesome/free-solid-svg-icons";
import React, {useContext, useState} from "react";
import {ViewContext} from "@/common/contexts/View";
import ListImage from "./images/list.png";
import StatisticImage from "./images/statistic.png";
import {t} from "i18next";
import "./styles.sass";
export const Dialog = () => {
const close = useContext(DialogContext);
const [setDialog] = useContext(InputDialogContext);
const [view, setView] = useContext(ViewContext);
const [selected, setSelected] = useState(view);
const submitForm = () => {
close();
setView(selected);
setDialog({title: "MySpeed", description: t('dropdown.changes_applied'), buttonText: t('dialog.okay')});
}
return (
<>
<div className="dialog-header">
<h4 className="dialog-text">{t("update.view_title")}</h4>
<FontAwesomeIcon icon={faClose} className="dialog-text dialog-icon" onClick={() => close()}/>
</div>
<div className="chooser-dialog">
<div className="chooser-item" onClick={() => setSelected(0)}>
<img src={ListImage} alt={t("test.views.list")}
className={"dialog-thumbnail" + (selected === 0 ? " thumbnail-selected" : "")}/>
<p className={selected === 0 ? "text-selected" : ""}>{t("test.views.list")}</p>
</div>
<div className="chooser-item" onClick={() => setSelected(1)}>
<img src={StatisticImage} alt={t("test.views.statistic")}
className={"dialog-thumbnail" + (selected === 1 ? " thumbnail-selected" : "")}/>
<p className={selected === 1 ? "text-selected" : ""}>{t("test.views.statistic")}</p>
</div>
</div>
<div className="dialog-buttons">
<button className="dialog-btn" onClick={submitForm}>{t("dialog.update")}</button>
</div>
</>
);
}
export const ViewDialog = (props) => {
return (
<DialogProvider close={props.onClose}>
<Dialog/>
</DialogProvider>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

View File

@@ -0,0 +1 @@
export {ViewDialog as default} from "./ViewDialog";

View File

@@ -0,0 +1,45 @@
@import "@/common/styles/colors"
.chooser-dialog
display: flex
justify-content: center
align-items: center
padding: 2rem 3.5rem
gap: 1rem
.chooser-item
display: flex
flex-direction: column
align-items: center
.dialog-thumbnail
width: 192px
height: 108px
border-radius: 0.75rem
border: 0.2rem solid $darker-gray
.thumbnail-selected
border: 0.2rem solid $green
.chooser-item:hover
cursor: pointer
& .dialog-thumbnail
border: 0.2rem solid $green-hover
& p
color: $green-hover
.chooser-item p
margin-top: 0.3rem
margin-bottom: 0
font-size: 14pt
color: $darker-white
.chooser-item .text-selected
color: $green
@media screen and (max-width: 30rem)
.chooser-dialog
flex-direction: column
padding: 2rem 5rem

View File

@@ -0,0 +1,18 @@
import React, {useState, createContext} from "react";
export const ViewContext = createContext({});
export const ViewProvider = (props) => {
const [view, setView] = useState(parseInt(localStorage.getItem("view")) || 0);
const updateView = (newView) => {
setView(newView);
localStorage.setItem("view", newView);
}
return (
<ViewContext.Provider value={[view, updateView]}>
{props.children}
</ViewContext.Provider>
)
}

View File

@@ -0,0 +1 @@
export * from './ViewContext';

View File

@@ -1,6 +1,6 @@
$background: #232835
$gray: #727676
$gray: #687071
$dark-gray: #1d2128
$darker-gray: #20252F

View File

@@ -7,6 +7,7 @@ export const Error = (props) => {
const [reloadTimer, setReloadTimer] = useState(5);
useEffect(() => {
if (props.disableReload) return;
const interval = setInterval(() => {
if (reloadTimer > 0) {
setReloadTimer(reloadTimer - 1)
@@ -19,10 +20,11 @@ export const Error = (props) => {
}, [reloadTimer]);
return (
<div className="error-page">
<div className={"error-page" + (props.disableReload ? " no-reload" : "")}>
<FontAwesomeIcon icon={faExclamationTriangle} size="8x"/>
<h1>{props.text}</h1>
<h2>Reloading {reloadTimer !== 0 ? <>in <span>{reloadTimer}</span> seconds</> : <span>now</span>}...</h2>
{!props.disableReload && <h2>Reloading {reloadTimer !== 0 ? <>in <span>{reloadTimer}</span> seconds</> :
<span>now</span>}...</h2>}
</div>
)
}

View File

@@ -11,6 +11,12 @@
color: $white
text-align: center
.no-reload
width: unset
height: unset
position: center
display: unset
.error-page svg
color: $red

View File

@@ -0,0 +1,77 @@
import {
ArcElement,
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
LineElement,
PointElement,
RadialLinearScale,
Title,
Tooltip
} from "chart.js";
import {SpeedtestContext} from "@/common/contexts/Speedtests";
import {useContext, useEffect, useState} from "react";
import Failed from "@/pages/Statistics/charts/FailedChart";
import {jsonRequest} from "@/common/utils/RequestUtil";
import SpeedChart from "@/pages/Statistics/charts/SpeedChart";
import LatestTestChart from "@/pages/Statistics/charts/LatestTestChart";
import PingChart from "@/pages/Statistics/charts/PingChart";
import DurationChart from "@/pages/Statistics/charts/DurationChart";
import OverviewChart from "@/pages/Statistics/charts/OverviewChart";
import ManualChart from "@/pages/Statistics/charts/ManualChart";
import AverageChart from "@/pages/Statistics/charts/AverageChart";
import i18n, {t} from "i18next";
import "./styles.sass";
const generatePath = (level = 1) => {
if (level <= 2) return level;
if (level === 3) return 7;
return 30;
}
ChartJS.register(ArcElement, Tooltip, CategoryScale, LinearScale, PointElement, LineElement, Title, Legend, RadialLinearScale);
ChartJS.defaults.color = "#B0B0B0";
ChartJS.defaults.font.color = "#B0B0B0";
ChartJS.defaults.font.family = "Roboto";
export const Statistics = () => {
const [statistics, setStatistics] = useState(null);
const [tests] = useContext(SpeedtestContext);
const updateStats = () => jsonRequest("/speedtests/statistics/?days=" + generatePath(parseInt(localStorage.getItem("testTime") || 1)))
.then(statistics => setStatistics(statistics));
useEffect(() => {
updateStats();
}, [tests]);
useEffect(() => {
const callback = () => updateStats();
i18n.on("languageChanged", callback);
return () => i18n.off("languageChanged", callback);
}, []);
if (!statistics) return <></>;
if (!tests) return <></>;
if (tests.length === 0) return <h2 className="error-text">{t("test.not_available")}</h2>;
return (
<div className="statistic-area">
<OverviewChart tests={statistics.tests} time={statistics.time}/>
<LatestTestChart test={tests.length !== 0 ? tests[0] : null}/>
<Failed tests={statistics.tests}/>
<SpeedChart labels={statistics.labels} data={statistics.data}/>
<ManualChart tests={statistics.tests}/>
<DurationChart time={statistics.data.time}/>
<PingChart labels={statistics.labels} data={statistics.data}/>
<AverageChart title={t("statistics.values.down")} data={statistics.download}/>
<AverageChart title={t("statistics.values.up")} data={statistics.upload}/>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faGauge, faMinusCircle, faPlusCircle} from "@fortawesome/free-solid-svg-icons";
import {t} from "i18next";
import "./styles.sass";
export const AverageChart = (props) => {
return (
<StatisticContainer title={props.title} size="small" center={true}>
<div className="value-container">
<div className="value-item">
<div className="value-info">
<h2>{t("statistics.values.min")}</h2>
<p>{props.data.min} {t("latest.speed_unit")}</p>
</div>
<FontAwesomeIcon icon={faMinusCircle}/>
</div>
<div className="value-item">
<div className="value-info">
<h2>{t("statistics.values.max")}</h2>
<p>{props.data.max} {t("latest.speed_unit")}</p>
</div>
<FontAwesomeIcon icon={faPlusCircle}/>
</div>
<div className="value-item">
<div className="value-info">
<h2>{t("statistics.values.avg")}</h2>
<p>{props.data.avg} {t("latest.speed_unit")}</p>
</div>
<FontAwesomeIcon icon={faGauge}/>
</div>
</div>
</StatisticContainer>
);
}

View File

@@ -0,0 +1 @@
export {AverageChart as default} from './AverageChart';

View File

@@ -0,0 +1,26 @@
@import "@/common/styles/colors"
.value-container
display: flex
flex-direction: column
gap: 2rem
justify-content: center
width: 100%
.value-item
display: flex
justify-content: space-between
.value-item svg
width: 2.5rem
height: 2.5rem
color: $green
.value-item .value-info h2
font-size: 16pt
color: $white
margin: 0
.value-item .value-info p
color: $green
margin: 0

View File

@@ -0,0 +1,63 @@
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {PolarArea} from "react-chartjs-2";
import {useEffect, useState} from "react";
import {t} from "i18next";
const chartOptions = {
plugins: {
legend: {
display: false
}
},
scales: {
r: {
ticks: {
color: "#B0B0B0",
backdropColor: "transparent",
}
}
}
}
const DurationChart = (props) => {
const chartData = {
labels: [],
datasets: [{
label: t("statistics.duration.label"),
data: [],
backgroundColor: ['#45C65A', '#456AC6', '#C64545', '#C6C645', '#C645C6'],
borderWidth: 0,
}],
};
const [data, setData] = useState({});
useEffect(() => {
if (!props.time) return;
const frequencies = {};
const tempData = {...chartData};
props.time.forEach(second => {
frequencies[second] ? frequencies[second]++ : frequencies[second] = 1;
});
for (let second in frequencies) {
tempData.labels.push(t("time.seconds", {replace: {seconds: second.toString()}}));
tempData.datasets[0].data.push(frequencies[second]);
}
setData(tempData);
}, [props.time]);
if (Object.keys(data).length === 0) return <></>;
return (
<StatisticContainer title={t("statistics.duration.title")} size="small" center={true}>
<PolarArea data={data} options={chartOptions}/>
</StatisticContainer>
);
}
export default DurationChart;

View File

@@ -0,0 +1,35 @@
import {Doughnut} from "react-chartjs-2";
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {t} from "i18next";
const chartOptions = {
plugins: {
legend: {
display: false
}
},
responsive: true,
cutout: 80
};
const FailedChart = (props) => {
const chartData = {
labels: [t("statistics.failed.success"), t("statistics.failed.failed")],
datasets: [{
label: t("statistics.failed.label"),
data: [props.tests.total - props.tests.failed, props.tests.failed],
backgroundColor: ['#456AC6', '#C64545'],
borderWidth: 0
}]
};
return (
<StatisticContainer title={t("statistics.failed.title")} center={true}>
<Doughnut data={chartData} options={chartOptions}/>
</StatisticContainer>
);
}
export default FailedChart;

View File

@@ -0,0 +1,51 @@
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faArrowDown, faArrowUp, faPingPongPaddleBall} from "@fortawesome/free-solid-svg-icons";
import "./styles.sass";
import {getIconBySpeed} from "@/common/utils/TestUtil";
import {useContext} from "react";
import {ConfigContext} from "@/common/contexts/Config";
import {t} from "i18next";
export const LatestTestChart = (props) => {
const [config] = useContext(ConfigContext);
if (!props.test) return <></>;
if (config === null) return <></>;
return (
<StatisticContainer title={t("latest.latest")}>
<div className="info-container">
<div className="test-container">
<div className="test-info">
<h2>{t("latest.ping")}</h2>
<p className={"icon-" + getIconBySpeed(props.test.ping, config.ping, false)}>
{(props.test.ping === -1 ? "N/A" : props.test.ping) + " " + t("latest.ping_unit")}</p>
</div>
<FontAwesomeIcon icon={faPingPongPaddleBall}
className={"icon-" + getIconBySpeed(props.test.ping, config.ping, false)}/>
</div>
<div className="test-container">
<div className="test-info">
<h2>{t("latest.up")}</h2>
<p className={"icon-" + getIconBySpeed(props.test.upload, config.upload, true)}>
{(props.test.upload === -1 ? "N/A" : props.test.upload) + " " + t("latest.speed_unit")}</p>
</div>
<FontAwesomeIcon icon={faArrowUp}
className={"icon-" + getIconBySpeed(props.test.upload, config.upload, true)}/>
</div>
<div className="test-container">
<div className="test-info">
<h2>{t("latest.down")}</h2>
<p className={"icon-" + getIconBySpeed(props.test.download, config.download, true)}>
{(props.test.download === -1 ? "N/A" : props.test.download) + " " + t("latest.speed_unit")}</p>
</div>
<FontAwesomeIcon icon={faArrowDown}
className={"icon-" + getIconBySpeed(props.test.download, config.download, true)}/>
</div>
</div>
</StatisticContainer>
);
}

View File

@@ -0,0 +1 @@
export {LatestTestChart as default} from "./LatestTestChart";

View File

@@ -0,0 +1,23 @@
@import "@/common/styles/colors"
.info-container
display: flex
flex-direction: column
gap: 2rem
justify-content: center
width: 100%
.test-container
display: flex
justify-content: space-between
.test-container svg
width: 3rem
height: 3rem
.test-container .test-info h2
color: $white
margin: 0
.test-container .test-info p
margin: 0

View File

@@ -0,0 +1,34 @@
import {Doughnut} from "react-chartjs-2";
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {t} from "i18next";
const chartOptions = {
plugins: {
legend: {
display: false
}
},
cutout: 80
};
const ManuelChart = (props) => {
const chartData = {
labels: [t("statistics.manual.yes"), t("statistics.manual.no")],
datasets: [{
label: t("statistics.failed.label"),
data: [props.tests.custom, props.tests.total - props.tests.custom],
backgroundColor: ['#456AC6', '#45C65A'],
borderWidth: 0
}]
};
return (
<StatisticContainer title={t("statistics.manual.title")} center={true}>
<Doughnut data={chartData} options={chartOptions}/>
</StatisticContainer>
);
}
export default ManuelChart;

View File

@@ -0,0 +1,51 @@
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {t} from "i18next";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faCircleExclamation, faGaugeHigh, faStopwatch} from "@fortawesome/free-solid-svg-icons";
import "./styles.sass";
export const OverviewChart = (props) => {
const title = t("test.overview.title", {replace: {amount: t("test.overview." + (localStorage.getItem("testTime") || 1))}});
const items = [
{
icon: faGaugeHigh,
title: "statistics.overview.total_title",
description: "statistics.overview.total_description",
value: props.tests.total
},
{
icon: faCircleExclamation,
title: "statistics.overview.failed_title",
description: "statistics.overview.failed_description",
value: props.tests.failed
},
{
icon: faStopwatch,
title: "statistics.overview.average_title",
description: "statistics.overview.average_description",
value: props.time.avg + "s"
}
];
return (
<StatisticContainer title={title} size="large">
<div className="overview-items">
{items.map((item, index) => (
<div className="overview-item" key={index}>
<div className="info-area">
<FontAwesomeIcon icon={item.icon} />
<div className="text-area">
<h2>{t(item.title)}</h2>
<p>{t(item.description)}</p>
</div>
</div>
<h2>{item.value}</h2>
</div>
))}
</div>
</StatisticContainer>
);
}

View File

@@ -0,0 +1 @@
export {OverviewChart as default} from "./OverviewChart";

View File

@@ -0,0 +1,56 @@
@import "@/common/styles/colors"
.overview-items
display: flex
justify-content: center
flex-direction: column
width: 100%
overflow-y: clip
.overview-item
display: flex
justify-content: space-between
.overview-item .info-area
display: flex
justify-content: center
align-content: center
align-items: center
gap: 1rem
& svg
width: 2.5rem
height: 2.5rem
color: $white
.overview-item .info-area .text-area
& h2
margin: 0
color: $white
& p
margin: 0
color: $darker-white
.overview-item h2
color: $green
@media screen and (max-width: 1500px)
.overview-item
flex-wrap: wrap
flex-direction: row
@media screen and (max-width: 1400px)
.info-area svg
display: none
.overview-item .info-area h2
display: inline-block
width: 15rem
white-space: nowrap
overflow: hidden !important
text-overflow: ellipsis
.overview-item .info-area .text-area p
display: none
@media screen and (max-width: 1000px)
.overview-item .info-area h2
width: 10rem

View File

@@ -0,0 +1,39 @@
import {Line} from "react-chartjs-2";
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {t} from "i18next";
const chartOptions = {
plugins: {
tooltip: {
callbacks: {
label: (item) => item.dataset.label + ": " + item.formattedValue + " " + t("latest.ping_unit")
}
},
legend: {
display: false
}
},
scales: {x: {reverse: true}},
responsive: true
};
const SpeedChart = (props) => {
const chartData = {
labels: props.labels,
datasets: [
{
label: t("latest.ping"),
data: props.data.ping,
borderColor: '#45C65A',
},
],
};
return (
<StatisticContainer title={t("latest.ping")} size="normal" center={true}>
<Line data={chartData} options={chartOptions}/>
</StatisticContainer>
)
}
export default SpeedChart;

View File

@@ -0,0 +1,35 @@
import {Line} from "react-chartjs-2";
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {t} from "i18next";
const chartOptions = {
plugins: {
tooltip: {
callbacks: {
label: (item) => item.dataset.label + ": " + item.formattedValue + " " + t("latest.speed_unit")
}
},
legend: {
position: "bottom"
}
},
scales: {x: {reverse: true}}
};
const SpeedChart = (props) => {
const chartData = {
labels: props.labels,
datasets: [
{label: t("latest.down"), data: props.data.download, borderColor: '#45C65A'},
{label: t("latest.up"), data: props.data.upload, borderColor: '#456AC6'},
],
};
return (
<StatisticContainer title={t("statistics.speed.title")} size="normal" center={true}>
<Line data={chartData} options={chartOptions}/>
</StatisticContainer>
)
}
export default SpeedChart;

View File

@@ -0,0 +1,15 @@
import "./styles.sass";
export const StatisticContainer = (props) => {
return (
<div className={"stats-container" + (props.size ? " container-" + props.size : "")}>
<div className="stats-header">
{props.title}
</div>
<div className={"stats-content " + (props.center ?" container-center" : "")}>
{props.children}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export {StatisticContainer as default} from './StatisticContainer';

View File

@@ -0,0 +1,43 @@
@import "@/common/styles/colors"
.stats-container
display: flex
flex-direction: column
min-width: 16rem
border: 1px solid $gray
border-radius: 1rem
cursor: crosshair
transition: all 0.2s ease-in-out
animation: 0.3s fadeIn
flex: 1 0 15%
.stats-container:hover
background-color: $darker-gray
border: 1px solid $white
transform: scale(1.05)
.stats-header
color: $white
font-size: 16pt
border-radius: 1rem 1rem 0 0
padding: 0.75rem 0.5rem 0.5rem 1rem
.stats-content
display: flex
padding-bottom: 1rem
padding-left: 1.5rem
height: 14rem
padding-right: 1.5rem
.container-center
justify-content: center
align-items: center
.container-small
flex: 1 0 5%
.container-normal
flex: 1 0 30%
.container-large
flex: 1 0 35%

View File

@@ -0,0 +1 @@
export {Statistics as default} from "./Statistics";

View File

@@ -0,0 +1,26 @@
@import "@/common/styles/colors"
.statistic-area
margin-left: 20rem
margin-right: 20rem
padding-top: 1.5rem
display: flex
gap: 2rem
justify-content: space-between
flex-wrap: wrap
transition: 1s all ease-in-out
@media screen and (max-width: 1472px)
.statistic-area
margin-left: 10rem
margin-right: 10rem
@media screen and (max-width: 425px)
.statistic-area
margin-left: 3rem
margin-right: 3rem
@media screen and (max-width: 375px)
.statistic-area
margin-left: 1rem
margin-right: 1rem

View File

@@ -74,6 +74,48 @@ module.exports.listAverage = async (days) => {
return result;
}
const mapFixed = (entries, type) => ({
min: Math.min(...entries.map((entry) => entry[type])),
max: Math.max(...entries.map((entry) => entry[type])),
avg: parseFloat((entries.reduce((a, b) => a + b[type], 0) / entries.length).toFixed(2))
});
const mapRounded = (entries, type) => ({
min: Math.min(...entries.map((entry) => entry[type])),
max: Math.max(...entries.map((entry) => entry[type])),
avg: Math.round(entries.reduce((a, b) => a + b[type], 0) / entries.length)
});
module.exports.listStatistics = async (days) => {
let dbEntries = (await tests.findAll({order: [["created", "DESC"]]}))
.filter((entry) => new Date(entry.created) > new Date().getTime() - (days <= 30 ? days : 30 ) * 24 * 3600000);
let avgEntries = [];
if (days >= 3) avgEntries = await this.listAverage(days);
let notFailed = dbEntries.filter((entry) => entry.error === null);
return {
tests: {
total: dbEntries.length,
failed: dbEntries.length - notFailed.length,
custom: dbEntries.filter((entry) => entry.type === "custom").length
},
ping: mapRounded(notFailed, "ping"),
download: mapFixed(notFailed, "download"),
upload: mapFixed(notFailed, "upload"),
time: mapRounded(notFailed, "time"),
data: {
ping: days >= 3 ? avgEntries.map((entry) => entry.ping) : notFailed.map((entry) => entry.ping),
download: days >= 3 ? avgEntries.map((entry) => entry.download) : notFailed.map((entry) => entry.download),
upload: days >= 3 ? avgEntries.map((entry) => entry.upload) : notFailed.map((entry) => entry.upload),
time: days >= 3 ? avgEntries.map((entry) => entry.time) : notFailed.map((entry) => entry.time)
},
labels: days >= 3 ? avgEntries.map((entry) => new Date(entry.created).toLocaleDateString())
: notFailed.map((entry) => new Date(entry.created).toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"}))
};
}
// Gets the latest speedtest from the database
module.exports.latest = async () => {
let speedtest = await tests.findOne({order: [["created", "DESC"]]});

View File

@@ -15,6 +15,10 @@ app.get("/averages", password(true), async (req, res) => {
res.json(await tests.listAverage(req.query.days || 7));
});
app.get("/statistics", password(true), async (req, res) => {
res.json(await tests.listStatistics(req.query.days || 1));
});
// Runs a speedtest
app.post("/run", password(false), async (req, res) => {
if (pauseController.currentState) return res.status(410).json({message: "The speedtests are currently paused"});