mirror of
https://github.com/gnmyt/myspeed.git
synced 2026-01-07 13:39:46 -06:00
Merge pull request #215 from gnmyt/features/statistics
📈 Neue Statistik-Seite
This commit is contained in:
46
client/package-lock.json
generated
46
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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%
|
||||
|
||||
56
client/src/common/components/ViewDialog/ViewDialog.jsx
Normal file
56
client/src/common/components/ViewDialog/ViewDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
client/src/common/components/ViewDialog/images/list.png
Normal file
BIN
client/src/common/components/ViewDialog/images/list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
BIN
client/src/common/components/ViewDialog/images/statistic.png
Normal file
BIN
client/src/common/components/ViewDialog/images/statistic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
1
client/src/common/components/ViewDialog/index.js
Normal file
1
client/src/common/components/ViewDialog/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export {ViewDialog as default} from "./ViewDialog";
|
||||
45
client/src/common/components/ViewDialog/styles.sass
Normal file
45
client/src/common/components/ViewDialog/styles.sass
Normal 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
|
||||
18
client/src/common/contexts/View/ViewContext.jsx
Normal file
18
client/src/common/contexts/View/ViewContext.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
client/src/common/contexts/View/index.js
Normal file
1
client/src/common/contexts/View/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ViewContext';
|
||||
@@ -1,6 +1,6 @@
|
||||
$background: #232835
|
||||
|
||||
$gray: #727676
|
||||
$gray: #687071
|
||||
$dark-gray: #1d2128
|
||||
$darker-gray: #20252F
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,12 @@
|
||||
color: $white
|
||||
text-align: center
|
||||
|
||||
.no-reload
|
||||
width: unset
|
||||
height: unset
|
||||
position: center
|
||||
display: unset
|
||||
|
||||
.error-page svg
|
||||
color: $red
|
||||
|
||||
|
||||
77
client/src/pages/Statistics/Statistics.jsx
Normal file
77
client/src/pages/Statistics/Statistics.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
}
|
||||
1
client/src/pages/Statistics/charts/AverageChart/index.js
Normal file
1
client/src/pages/Statistics/charts/AverageChart/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export {AverageChart as default} from './AverageChart';
|
||||
26
client/src/pages/Statistics/charts/AverageChart/styles.sass
Normal file
26
client/src/pages/Statistics/charts/AverageChart/styles.sass
Normal 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
|
||||
63
client/src/pages/Statistics/charts/DurationChart.jsx
Normal file
63
client/src/pages/Statistics/charts/DurationChart.jsx
Normal 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;
|
||||
35
client/src/pages/Statistics/charts/FailedChart.jsx
Normal file
35
client/src/pages/Statistics/charts/FailedChart.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {LatestTestChart as default} from "./LatestTestChart";
|
||||
@@ -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
|
||||
34
client/src/pages/Statistics/charts/ManualChart.jsx
Normal file
34
client/src/pages/Statistics/charts/ManualChart.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {OverviewChart as default} from "./OverviewChart";
|
||||
56
client/src/pages/Statistics/charts/OverviewChart/styles.sass
Normal file
56
client/src/pages/Statistics/charts/OverviewChart/styles.sass
Normal 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
|
||||
39
client/src/pages/Statistics/charts/PingChart.jsx
Normal file
39
client/src/pages/Statistics/charts/PingChart.jsx
Normal 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;
|
||||
35
client/src/pages/Statistics/charts/SpeedChart.jsx
Normal file
35
client/src/pages/Statistics/charts/SpeedChart.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {StatisticContainer as default} from './StatisticContainer';
|
||||
@@ -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%
|
||||
1
client/src/pages/Statistics/index.js
Normal file
1
client/src/pages/Statistics/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export {Statistics as default} from "./Statistics";
|
||||
26
client/src/pages/Statistics/styles.sass
Normal file
26
client/src/pages/Statistics/styles.sass
Normal 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
|
||||
@@ -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"]]});
|
||||
|
||||
@@ -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"});
|
||||
|
||||
Reference in New Issue
Block a user