diff --git a/client/package-lock.json b/client/package-lock.json
index ccc4bc54..602b2b03 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -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",
diff --git a/client/package.json b/client/package.json
index 32b67848..14ee29ed 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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"
diff --git a/client/public/locales/de.json b/client/public/locales/de.json
index 7b06407b..ba52cf97 100644
--- a/client/public/locales/de.json
+++ b/client/public/locales/de.json
@@ -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 hier."
+ }
},
"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"
+ }
}
}
diff --git a/client/public/locales/en.json b/client/public/locales/en.json
index f4452953..650383f5 100644
--- a/client/public/locales/en.json
+++ b/client/public/locales/en.json
@@ -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 here."
+ }
},
"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"
+ }
}
-}
\ No newline at end of file
+}
diff --git a/client/src/App.jsx b/client/src/App.jsx
index ce3b9305..ae26d563 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -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 (
+
+ {view === 0 && }
+ {view === 1 && }
+ {view !== 0 && view !== 1 && }
+
+ );
+}
const App = () => {
const [translationsLoaded, setTranslationsLoaded] = useState(false);
@@ -24,14 +38,14 @@ const App = () => {
{translationError && }
{translationsLoaded && !translationError &&
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
}
>
diff --git a/client/src/common/components/Dropdown/DropdownComponent.jsx b/client/src/common/components/Dropdown/DropdownComponent.jsx
index 3f4d927a..f5ed4e56 100644
--- a/client/src/common/components/Dropdown/DropdownComponent.jsx
+++ b/client/src/common/components/Dropdown/DropdownComponent.jsx
@@ -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 (
-
-
-
{t("dropdown.settings")}
-
- {options.map(entry => {
- if (!config.viewMode || (config.viewMode && entry.allowView)) {
- if (!entry.hr) {
- return (
-
- {entry.text}
+ <>
+ {showViewDialog && setShowViewDialog(false)}/>}
+
+
+
{t("dropdown.settings")}
+
+ {options.map(entry => {
+ if (!config.viewMode || (config.viewMode && entry.allowView)) {
+ if (!entry.hr) {
+ return (
+
+ {entry.text}
+
);
+ } else return (
+
);
- } else return (
);
- }
- })}
+ }
+ })}
+
-
- )
+ >
+ );
}
export default DropdownComponent;
\ No newline at end of file
diff --git a/client/src/common/components/Header/HeaderComponent.jsx b/client/src/common/components/Header/HeaderComponent.jsx
index f21835fe..f80ab56f 100644
--- a/client/src/common/components/Header/HeaderComponent.jsx
+++ b/client/src/common/components/Header/HeaderComponent.jsx
@@ -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() {
-
{t("header.title")}
+
{t("header.title")} {view === 1 && setDialog({
+ title: t("header.beta.title"),
+ description: }}>header.beta.description,
+ buttonText: t("dialog.okay")
+ })}>BETA}
{updateAvailable ?
{
+ 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 (
+ <>
+
+
{t("update.view_title")}
+ close()}/>
+
+
+
setSelected(0)}>
+

+
{t("test.views.list")}
+
+
setSelected(1)}>
+

+
{t("test.views.statistic")}
+
+
+
+
+
+ >
+ );
+
+}
+
+export const ViewDialog = (props) => {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/client/src/common/components/ViewDialog/images/list.png b/client/src/common/components/ViewDialog/images/list.png
new file mode 100644
index 00000000..109a4e43
Binary files /dev/null and b/client/src/common/components/ViewDialog/images/list.png differ
diff --git a/client/src/common/components/ViewDialog/images/statistic.png b/client/src/common/components/ViewDialog/images/statistic.png
new file mode 100644
index 00000000..042e2f5a
Binary files /dev/null and b/client/src/common/components/ViewDialog/images/statistic.png differ
diff --git a/client/src/common/components/ViewDialog/index.js b/client/src/common/components/ViewDialog/index.js
new file mode 100644
index 00000000..478b3776
--- /dev/null
+++ b/client/src/common/components/ViewDialog/index.js
@@ -0,0 +1 @@
+export {ViewDialog as default} from "./ViewDialog";
\ No newline at end of file
diff --git a/client/src/common/components/ViewDialog/styles.sass b/client/src/common/components/ViewDialog/styles.sass
new file mode 100644
index 00000000..5bf55062
--- /dev/null
+++ b/client/src/common/components/ViewDialog/styles.sass
@@ -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
\ No newline at end of file
diff --git a/client/src/common/contexts/View/ViewContext.jsx b/client/src/common/contexts/View/ViewContext.jsx
new file mode 100644
index 00000000..a5a7f5a6
--- /dev/null
+++ b/client/src/common/contexts/View/ViewContext.jsx
@@ -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 (
+
+ {props.children}
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/common/contexts/View/index.js b/client/src/common/contexts/View/index.js
new file mode 100644
index 00000000..84fbd787
--- /dev/null
+++ b/client/src/common/contexts/View/index.js
@@ -0,0 +1 @@
+export * from './ViewContext';
\ No newline at end of file
diff --git a/client/src/common/styles/_colors.sass b/client/src/common/styles/_colors.sass
index 6733beb5..2397c0f9 100644
--- a/client/src/common/styles/_colors.sass
+++ b/client/src/common/styles/_colors.sass
@@ -1,6 +1,6 @@
$background: #232835
-$gray: #727676
+$gray: #687071
$dark-gray: #1d2128
$darker-gray: #20252F
diff --git a/client/src/pages/Error/Error.jsx b/client/src/pages/Error/Error.jsx
index 7a273ee9..7b84ee50 100644
--- a/client/src/pages/Error/Error.jsx
+++ b/client/src/pages/Error/Error.jsx
@@ -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 (
-
+
{props.text}
- Reloading {reloadTimer !== 0 ? <>in {reloadTimer} seconds> : now}...
+ {!props.disableReload && Reloading {reloadTimer !== 0 ? <>in {reloadTimer} seconds> :
+ now}...
}
)
}
\ No newline at end of file
diff --git a/client/src/pages/Error/styles.sass b/client/src/pages/Error/styles.sass
index 763b77f1..3e74f78a 100644
--- a/client/src/pages/Error/styles.sass
+++ b/client/src/pages/Error/styles.sass
@@ -11,6 +11,12 @@
color: $white
text-align: center
+.no-reload
+ width: unset
+ height: unset
+ position: center
+ display: unset
+
.error-page svg
color: $red
diff --git a/client/src/pages/Statistics/Statistics.jsx b/client/src/pages/Statistics/Statistics.jsx
new file mode 100644
index 00000000..18c9f7f6
--- /dev/null
+++ b/client/src/pages/Statistics/Statistics.jsx
@@ -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
{t("test.not_available")}
;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/AverageChart/AverageChart.jsx b/client/src/pages/Statistics/charts/AverageChart/AverageChart.jsx
new file mode 100644
index 00000000..4a4a29b3
--- /dev/null
+++ b/client/src/pages/Statistics/charts/AverageChart/AverageChart.jsx
@@ -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 (
+
+
+
+
+
{t("statistics.values.min")}
+
{props.data.min} {t("latest.speed_unit")}
+
+
+
+
+
+
{t("statistics.values.max")}
+
{props.data.max} {t("latest.speed_unit")}
+
+
+
+
+
+
{t("statistics.values.avg")}
+
{props.data.avg} {t("latest.speed_unit")}
+
+
+
+
+
+ );
+
+}
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/AverageChart/index.js b/client/src/pages/Statistics/charts/AverageChart/index.js
new file mode 100644
index 00000000..85e4c421
--- /dev/null
+++ b/client/src/pages/Statistics/charts/AverageChart/index.js
@@ -0,0 +1 @@
+export {AverageChart as default} from './AverageChart';
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/AverageChart/styles.sass b/client/src/pages/Statistics/charts/AverageChart/styles.sass
new file mode 100644
index 00000000..b6e2bf98
--- /dev/null
+++ b/client/src/pages/Statistics/charts/AverageChart/styles.sass
@@ -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
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/DurationChart.jsx b/client/src/pages/Statistics/charts/DurationChart.jsx
new file mode 100644
index 00000000..ea27f29f
--- /dev/null
+++ b/client/src/pages/Statistics/charts/DurationChart.jsx
@@ -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 (
+
+
+
+ );
+
+}
+
+export default DurationChart;
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/FailedChart.jsx b/client/src/pages/Statistics/charts/FailedChart.jsx
new file mode 100644
index 00000000..93d815bd
--- /dev/null
+++ b/client/src/pages/Statistics/charts/FailedChart.jsx
@@ -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 (
+
+
+
+ );
+}
+
+
+export default FailedChart;
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/LatestTestChart/LatestTestChart.jsx b/client/src/pages/Statistics/charts/LatestTestChart/LatestTestChart.jsx
new file mode 100644
index 00000000..8b7c481b
--- /dev/null
+++ b/client/src/pages/Statistics/charts/LatestTestChart/LatestTestChart.jsx
@@ -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 (
+
+
+
+
+
{t("latest.ping")}
+
+ {(props.test.ping === -1 ? "N/A" : props.test.ping) + " " + t("latest.ping_unit")}
+
+
+
+
+
+
{t("latest.up")}
+
+ {(props.test.upload === -1 ? "N/A" : props.test.upload) + " " + t("latest.speed_unit")}
+
+
+
+
+
+
{t("latest.down")}
+
+ {(props.test.download === -1 ? "N/A" : props.test.download) + " " + t("latest.speed_unit")}
+
+
+
+
+
+ );
+
+}
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/LatestTestChart/index.js b/client/src/pages/Statistics/charts/LatestTestChart/index.js
new file mode 100644
index 00000000..f2fe152c
--- /dev/null
+++ b/client/src/pages/Statistics/charts/LatestTestChart/index.js
@@ -0,0 +1 @@
+export {LatestTestChart as default} from "./LatestTestChart";
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/LatestTestChart/styles.sass b/client/src/pages/Statistics/charts/LatestTestChart/styles.sass
new file mode 100644
index 00000000..41cf1b3c
--- /dev/null
+++ b/client/src/pages/Statistics/charts/LatestTestChart/styles.sass
@@ -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
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/ManualChart.jsx b/client/src/pages/Statistics/charts/ManualChart.jsx
new file mode 100644
index 00000000..5f001fea
--- /dev/null
+++ b/client/src/pages/Statistics/charts/ManualChart.jsx
@@ -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 (
+
+
+
+ );
+}
+
+
+export default ManuelChart;
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/OverviewChart/OverviewChart.jsx b/client/src/pages/Statistics/charts/OverviewChart/OverviewChart.jsx
new file mode 100644
index 00000000..80e115a7
--- /dev/null
+++ b/client/src/pages/Statistics/charts/OverviewChart/OverviewChart.jsx
@@ -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 (
+
+
+ {items.map((item, index) => (
+
+
+
+
+
{t(item.title)}
+
{t(item.description)}
+
+
+
{item.value}
+
+ ))}
+
+
+ );
+
+}
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/OverviewChart/index.js b/client/src/pages/Statistics/charts/OverviewChart/index.js
new file mode 100644
index 00000000..562b8ee6
--- /dev/null
+++ b/client/src/pages/Statistics/charts/OverviewChart/index.js
@@ -0,0 +1 @@
+export {OverviewChart as default} from "./OverviewChart";
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/OverviewChart/styles.sass b/client/src/pages/Statistics/charts/OverviewChart/styles.sass
new file mode 100644
index 00000000..489a83b3
--- /dev/null
+++ b/client/src/pages/Statistics/charts/OverviewChart/styles.sass
@@ -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
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/PingChart.jsx b/client/src/pages/Statistics/charts/PingChart.jsx
new file mode 100644
index 00000000..cd5ee80f
--- /dev/null
+++ b/client/src/pages/Statistics/charts/PingChart.jsx
@@ -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 (
+
+
+
+ )
+
+}
+export default SpeedChart;
\ No newline at end of file
diff --git a/client/src/pages/Statistics/charts/SpeedChart.jsx b/client/src/pages/Statistics/charts/SpeedChart.jsx
new file mode 100644
index 00000000..4ac13a13
--- /dev/null
+++ b/client/src/pages/Statistics/charts/SpeedChart.jsx
@@ -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 (
+
+
+
+ )
+
+}
+export default SpeedChart;
\ No newline at end of file
diff --git a/client/src/pages/Statistics/components/StatisticContainer/StatisticContainer.jsx b/client/src/pages/Statistics/components/StatisticContainer/StatisticContainer.jsx
new file mode 100644
index 00000000..5c42f1be
--- /dev/null
+++ b/client/src/pages/Statistics/components/StatisticContainer/StatisticContainer.jsx
@@ -0,0 +1,15 @@
+import "./styles.sass";
+
+export const StatisticContainer = (props) => {
+
+ return (
+
+
+ {props.title}
+
+
+ {props.children}
+
+
+ );
+}
\ No newline at end of file
diff --git a/client/src/pages/Statistics/components/StatisticContainer/index.js b/client/src/pages/Statistics/components/StatisticContainer/index.js
new file mode 100644
index 00000000..74385f51
--- /dev/null
+++ b/client/src/pages/Statistics/components/StatisticContainer/index.js
@@ -0,0 +1 @@
+export {StatisticContainer as default} from './StatisticContainer';
\ No newline at end of file
diff --git a/client/src/pages/Statistics/components/StatisticContainer/styles.sass b/client/src/pages/Statistics/components/StatisticContainer/styles.sass
new file mode 100644
index 00000000..210d5a08
--- /dev/null
+++ b/client/src/pages/Statistics/components/StatisticContainer/styles.sass
@@ -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%
\ No newline at end of file
diff --git a/client/src/pages/Statistics/index.js b/client/src/pages/Statistics/index.js
new file mode 100644
index 00000000..33d668d3
--- /dev/null
+++ b/client/src/pages/Statistics/index.js
@@ -0,0 +1 @@
+export {Statistics as default} from "./Statistics";
\ No newline at end of file
diff --git a/client/src/pages/Statistics/styles.sass b/client/src/pages/Statistics/styles.sass
new file mode 100644
index 00000000..ab5f452b
--- /dev/null
+++ b/client/src/pages/Statistics/styles.sass
@@ -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
\ No newline at end of file
diff --git a/server/controller/speedtests.js b/server/controller/speedtests.js
index 1c54c173..cf93cbb4 100644
--- a/server/controller/speedtests.js
+++ b/server/controller/speedtests.js
@@ -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"]]});
diff --git a/server/routes/speedtests.js b/server/routes/speedtests.js
index dd3c497c..e81f8347 100644
--- a/server/routes/speedtests.js
+++ b/server/routes/speedtests.js
@@ -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"});