mirror of
https://github.com/gnmyt/myspeed.git
synced 2026-02-14 01:28:39 -06:00
Merge pull request #681 from gnmyt/updates/storage-management
💾 Storage Management hinzugefügt
This commit is contained in:
@@ -44,6 +44,8 @@
|
||||
"dropdown": {
|
||||
"settings": "Settings",
|
||||
"changes_applied": "Your changes have been saved.",
|
||||
"language_changed": "The language has been changed.",
|
||||
"provider_changed": "The provider has been changed.",
|
||||
"view_changed": "The view has been changed.",
|
||||
"changes_unsaved": "Your changes were not applied. Check your input.",
|
||||
"invalid": "Input invalid",
|
||||
@@ -55,7 +57,7 @@
|
||||
"password": "Change password",
|
||||
"cron": "Set frequency",
|
||||
"time": "Set period",
|
||||
"export": "Export tests",
|
||||
"storage": "Manage storage",
|
||||
"pause_tests": "Pause tests",
|
||||
"resume_tests": "Resume tests",
|
||||
"language": "Change language",
|
||||
@@ -78,10 +80,6 @@
|
||||
"rare": "Rarely (every 3 hours)",
|
||||
"really_rare": "Very rarely (every 6 hours)"
|
||||
},
|
||||
"export": {
|
||||
"json": "JSON file",
|
||||
"csv": "CSV file"
|
||||
},
|
||||
"level": {
|
||||
"no_access": "No Access",
|
||||
"read_access": "Read-only Access"
|
||||
@@ -107,7 +105,6 @@
|
||||
"cron_rules": "Cron rule",
|
||||
"cron_next_test": "Next Test:",
|
||||
"time_title": "Show tests of the last ...",
|
||||
"export_title": "Export speedtests",
|
||||
"download": "Download",
|
||||
"pause_title": "Pause speedtests for...",
|
||||
"hours": "Hours",
|
||||
@@ -130,6 +127,33 @@
|
||||
"description": "This feature is still in beta. If you find any bugs, please report them <Link>here</Link>."
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"speedtests": "Speedtests",
|
||||
"configuration": "Configuration",
|
||||
"stored_tests": "Stored tests",
|
||||
"tests": "Tests",
|
||||
"export_tests": "Export tests",
|
||||
"tests_exported": "The tests have been exported",
|
||||
"csv": "CSV",
|
||||
"json": "JSON",
|
||||
"import_tests": "Import tests",
|
||||
"tests_imported": "The tests have been imported",
|
||||
"import_error": "An error occurred while importing the tests",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"clear_history": "Clear history",
|
||||
"history_cleared": "The history has been cleared",
|
||||
"delete": "Delete",
|
||||
"confirm_delete": "Yes, delete",
|
||||
"export_settings": "Export settings",
|
||||
"import_settings": "Import settings",
|
||||
"factory_reset": "Factory reset",
|
||||
"factory_reset_completed": "The factory reset has been completed",
|
||||
"reset": "Reset",
|
||||
"confirm_reset": "Yes, reset",
|
||||
"settings_exported": "The settings have been exported",
|
||||
"settings_imported": "The settings have been imported"
|
||||
},
|
||||
"latest": {
|
||||
"ping": "Ping",
|
||||
"ping_unit": "ms",
|
||||
@@ -140,7 +164,7 @@
|
||||
"before": "before"
|
||||
},
|
||||
"info": {
|
||||
"credits": "<Link>MySpeed</Link> is provided by GNMYT and uses the <CLILink>Speedtest CLI</CLILink> from Ookla.",
|
||||
"credits": "<MSpeed>MySpeed</MSpeed> is a open source project provided by GNMYT. Leave a star on <Github>GitHub</Github> or <Donate>donate</Donate> to support the project.",
|
||||
"recommendations_error": "You have to do at least 10 tests to get an average. It doesn't matter if the tests were done manually or automatically.",
|
||||
"recommendations_info": "Based on the last 10 tests, it was found that the optimal ping was <Bold>{{ping}} ms</Bold>, the download at <Bold>{{down}} Mbps</Bold> and the upload at <Bold>{{up}} Mbps</Bold>. It is best to orientate yourself on your internet contract and only adopt it if it matches that.",
|
||||
"update": "An update to version {{version}} is available. See <Changes>the changes</Changes> and <DLLink>download the update</DLLink>.",
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
faCircleNodes,
|
||||
faClock,
|
||||
faClose,
|
||||
faFileExport,
|
||||
faChartSimple,
|
||||
faGear,
|
||||
faGlobeEurope,
|
||||
@@ -18,15 +17,15 @@ import {
|
||||
faPlay,
|
||||
faWandMagicSparkles,
|
||||
faCheck,
|
||||
faExclamationTriangle, faSliders
|
||||
faExclamationTriangle, faSliders, faHardDrive
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {ConfigContext} from "@/common/contexts/Config";
|
||||
import {StatusContext} from "@/common/contexts/Status";
|
||||
import {InputDialogContext} from "@/common/contexts/InputDialog";
|
||||
import {SpeedtestContext} from "@/common/contexts/Speedtests";
|
||||
import {baseRequest, downloadRequest, jsonRequest, patchRequest, postRequest} from "@/common/utils/RequestUtil";
|
||||
import {baseRequest, jsonRequest, patchRequest, postRequest} from "@/common/utils/RequestUtil";
|
||||
import {creditsInfo, recommendationsInfo} from "@/common/components/Dropdown/utils/infos";
|
||||
import {exportOptions, levelOptions, selectOptions, timeOptions} from "@/common/components/Dropdown/utils/options";
|
||||
import {levelOptions, selectOptions, timeOptions} from "@/common/components/Dropdown/utils/options";
|
||||
import {parseCron, stringifyCron} from "@/common/components/Dropdown/utils/utils";
|
||||
import {t} from "i18next";
|
||||
import ViewDialog from "@/common/components/ViewDialog";
|
||||
@@ -36,6 +35,7 @@ import {NodeContext} from "@/common/contexts/Node";
|
||||
import {IntegrationDialog} from "@/common/components/IntegrationDialog";
|
||||
import LanguageDialog from "@/common/components/LanguageDialog";
|
||||
import ProviderDialog from "@/common/components/ProviderDialog";
|
||||
import StorageDialog from "@/common/components/StorageDialog";
|
||||
|
||||
let icon;
|
||||
|
||||
@@ -66,6 +66,7 @@ function DropdownComponent() {
|
||||
const [showIntegrationDialog, setShowIntegrationDialog] = useState(false);
|
||||
const [showLanguageDialog, setShowLanguageDialog] = useState(false);
|
||||
const [showProviderDialog, setShowProviderDialog] = useState(false);
|
||||
const [showStorageDialog, setShowStorageDialog] = useState(false);
|
||||
const ref = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -192,17 +193,6 @@ function DropdownComponent() {
|
||||
});
|
||||
}
|
||||
|
||||
function exportDialog() {
|
||||
setDialog({
|
||||
select: true,
|
||||
title: t("update.export_title"),
|
||||
buttonText: t("update.download"),
|
||||
value: "json",
|
||||
selectOptions: exportOptions(),
|
||||
onSuccess: value => downloadRequest("/export/" + value)
|
||||
});
|
||||
}
|
||||
|
||||
const togglePause = () => {
|
||||
if (!status.paused) {
|
||||
setDialog({
|
||||
@@ -228,9 +218,9 @@ function DropdownComponent() {
|
||||
{run: recommendedSettings, icon: faWandMagicSparkles, text: t("dropdown.recommendations")},
|
||||
{hr: true, key: 1},
|
||||
{run: () => setShowProviderDialog(true), icon: faSliders, text: t("dropdown.change_provider")},
|
||||
{run: () => setShowStorageDialog(true), icon: faHardDrive, text: t("dropdown.storage")},
|
||||
{run: updatePassword, icon: faKey, text: t("dropdown.password"), previewHidden: true},
|
||||
{run: updateCron, icon: faClock, text: t("dropdown.cron")},
|
||||
{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: () => setShowIntegrationDialog(true), icon: faCircleNodes, text: t("dropdown.integrations")},
|
||||
{hr: true, key: 2},
|
||||
@@ -247,6 +237,7 @@ function DropdownComponent() {
|
||||
{showIntegrationDialog && <IntegrationDialog onClose={() => setShowIntegrationDialog(false)}/>}
|
||||
{showLanguageDialog && <LanguageDialog onClose={() => setShowLanguageDialog(false)}/>}
|
||||
{showProviderDialog && <ProviderDialog onClose={() => setShowProviderDialog(false)}/>}
|
||||
{showStorageDialog && <StorageDialog onClose={() => setShowStorageDialog(false)}/>}
|
||||
<div className="dropdown dropdown-invisible" id="dropdown" ref={ref}>
|
||||
<div className="dropdown-content">
|
||||
<h2>{t("dropdown.settings")}</h2>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {WEB_URL} from "@/index";
|
||||
import {DONATION_URL, PROJECT_URL, WEB_URL} from "@/index";
|
||||
import {Trans} from "react-i18next";
|
||||
const CLI_URL = "https://www.speedtest.net/apps/cli";
|
||||
|
||||
export const creditsInfo = () => <Trans components={{Link: <a href={WEB_URL} target="_blank" />,
|
||||
CLILink: <a href={CLI_URL} target="_blank"/>}}>info.credits</Trans>
|
||||
|
||||
export const creditsInfo = () => <Trans components={{MSpeed: <a href={WEB_URL} target="_blank" />,
|
||||
Github: <a href={PROJECT_URL} target="_blank" />, Donate: <a href={DONATION_URL} target="_blank" />}}>info.credits</Trans>
|
||||
|
||||
export const recommendationsInfo = (ping, down, up) => <Trans components={{Bold: <span className="dialog-value" />}}
|
||||
values={{ping, down, up}}>info.recommendations_info</Trans>
|
||||
@@ -18,9 +18,4 @@ export const selectOptions = () => ({
|
||||
"0 * * * *": t("options.cron.default"),
|
||||
"0 0,3,6,9,12,15,18,21 * * *": t("options.cron.rare"),
|
||||
"0 0,6,12,18 * * *": t("options.cron.really_rare")
|
||||
});
|
||||
|
||||
export const exportOptions = () => ({
|
||||
json: t("options.export.json"),
|
||||
csv: t("options.export.csv")
|
||||
});
|
||||
@@ -1,17 +1,20 @@
|
||||
import {DialogContext, DialogProvider} from "@/common/contexts/Dialog";
|
||||
import {t, changeLanguage} from "i18next";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faClose} from "@fortawesome/free-solid-svg-icons";
|
||||
import {faClose, faGlobe} from "@fortawesome/free-solid-svg-icons";
|
||||
import "./styles.sass";
|
||||
import {languages} from "@/i18n";
|
||||
import {useContext, useState} from "react";
|
||||
import {ToastNotificationContext} from "@/common/contexts/ToastNotification";
|
||||
|
||||
export const Dialog = () => {
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(localStorage.getItem("language") || "en");
|
||||
const updateToast = useContext(ToastNotificationContext);
|
||||
const close = useContext(DialogContext);
|
||||
|
||||
const updateLanguage = () => {
|
||||
changeLanguage(selectedLanguage);
|
||||
updateToast(t('dropdown.language_changed'), "green", faGlobe);
|
||||
close();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
cursor: pointer
|
||||
transition: background-color 0.3s
|
||||
border-radius: 0.5rem
|
||||
border: 2px solid $darker-gray
|
||||
|
||||
&:hover
|
||||
background-color: $darker-gray
|
||||
@@ -37,6 +38,7 @@
|
||||
.language-selected
|
||||
background-color: $light-gray
|
||||
color: $white
|
||||
border: 2px solid $light-gray
|
||||
|
||||
&:hover
|
||||
background-color: $light-gray
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {DialogContext, DialogProvider} from "@/common/contexts/Dialog";
|
||||
import {t} from "i18next";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faClose} from "@fortawesome/free-solid-svg-icons";
|
||||
import {faCheck, faClose} from "@fortawesome/free-solid-svg-icons";
|
||||
import "./styles.sass";
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
import OoklaImage from "./assets/img/ookla.webp";
|
||||
@@ -10,6 +10,7 @@ import CloudflareImage from "./assets/img/cloudflare.webp";
|
||||
import {jsonRequest, patchRequest} from "@/common/utils/RequestUtil";
|
||||
import {Trans} from "react-i18next";
|
||||
import {ConfigContext} from "@/common/contexts/Config";
|
||||
import {ToastNotificationContext} from "@/common/contexts/ToastNotification";
|
||||
|
||||
export const providers = [
|
||||
{id: "ookla", name: "Ookla", image: OoklaImage},
|
||||
@@ -21,6 +22,7 @@ export const providers = [
|
||||
export const Dialog = () => {
|
||||
const close = useContext(DialogContext);
|
||||
const [config, reloadConfig] = useContext(ConfigContext);
|
||||
const updateToast = useContext(ToastNotificationContext);
|
||||
const [provider, setProvider] = useState(config.provider || "ookla");
|
||||
|
||||
const [licenseAccepted, setLicenseAccepted] = useState(false);
|
||||
@@ -61,6 +63,7 @@ export const Dialog = () => {
|
||||
}
|
||||
|
||||
reloadConfig();
|
||||
updateToast(t('dropdown.provider_changed'), "green", faCheck);
|
||||
|
||||
close();
|
||||
}
|
||||
|
||||
69
client/src/common/components/StorageDialog/StorageDialog.jsx
Normal file
69
client/src/common/components/StorageDialog/StorageDialog.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import "./styles.sass";
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
import {DialogContext, DialogProvider} from "@/common/contexts/Dialog";
|
||||
import {t} from "i18next";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faClose, faDatabase, faGauge, faScrewdriverWrench} from "@fortawesome/free-solid-svg-icons";
|
||||
import Speedtests from "./tabs/Speedtests";
|
||||
import Configuration from "./tabs/Configuration";
|
||||
import {jsonRequest} from "@/common/utils/RequestUtil";
|
||||
|
||||
const Dialog = () => {
|
||||
const close = useContext(DialogContext);
|
||||
const [storageSize, setStorageSize] = useState({size: 0, testCount: 0});
|
||||
|
||||
const [currentTab, setCurrentTab] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
jsonRequest("/storage").then((res) => {
|
||||
setStorageSize(res);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header">
|
||||
<h4 className="dialog-text">{t("dropdown.storage")}</h4>
|
||||
<FontAwesomeIcon icon={faClose} className="dialog-text dialog-icon" onClick={() => close()}/>
|
||||
</div>
|
||||
<div className="storage-dialog">
|
||||
<div className="storage-options">
|
||||
<div className="storage-top">
|
||||
<div className={"storage-tab" + (1 === currentTab ? " storage-item-active" : "")}
|
||||
onClick={() => setCurrentTab(1)}>
|
||||
<FontAwesomeIcon icon={faGauge}/>
|
||||
<p>{t("storage.speedtests")}</p>
|
||||
</div>
|
||||
<div className={"storage-tab" + (2 === currentTab ? " storage-item-active" : "")}
|
||||
onClick={() => setCurrentTab(2)}>
|
||||
<FontAwesomeIcon icon={faScrewdriverWrench}/>
|
||||
<p>{t("storage.configuration")}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="storage-bottom">
|
||||
<div className="storage-tab reset-cursor">
|
||||
<FontAwesomeIcon icon={faDatabase}/>
|
||||
<p>{Math.round(storageSize.size / 1024)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="storage-manager">
|
||||
{currentTab === 1 && <Speedtests tests={storageSize.testCount}/>}
|
||||
{currentTab === 2 && <Configuration/>}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const StorageDialog = ({onClose}) => {
|
||||
return (
|
||||
<DialogProvider close={onClose}>
|
||||
<Dialog/>
|
||||
</DialogProvider>
|
||||
)
|
||||
}
|
||||
1
client/src/common/components/StorageDialog/index.js
Normal file
1
client/src/common/components/StorageDialog/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export {StorageDialog as default} from "./StorageDialog";
|
||||
91
client/src/common/components/StorageDialog/styles.sass
Normal file
91
client/src/common/components/StorageDialog/styles.sass
Normal file
@@ -0,0 +1,91 @@
|
||||
@import "@/common/styles/colors"
|
||||
|
||||
.storage-dialog
|
||||
display: flex
|
||||
margin-left: 0.5rem
|
||||
margin-right: 0.5rem
|
||||
gap: 1rem
|
||||
width: 45rem
|
||||
margin-top: 1rem
|
||||
height: 14rem
|
||||
user-select: none
|
||||
|
||||
.storage-options
|
||||
display: flex
|
||||
flex-direction: column
|
||||
justify-content: space-between
|
||||
|
||||
.storage-top
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 0.5rem
|
||||
user-select: none
|
||||
overflow-x: hidden
|
||||
overflow-y: scroll
|
||||
|
||||
.storage-tab
|
||||
display: flex
|
||||
gap: 0.5rem
|
||||
align-items: center
|
||||
padding: 0.6rem 0.7rem
|
||||
color: $darker-white
|
||||
border: 2px solid transparent
|
||||
border-radius: 1rem
|
||||
cursor: pointer
|
||||
|
||||
svg
|
||||
width: 1.3rem
|
||||
height: 1.3rem
|
||||
p
|
||||
margin: 0
|
||||
font-size: 14pt
|
||||
|
||||
|
||||
.reset-cursor
|
||||
cursor: default
|
||||
|
||||
.storage-item-active
|
||||
background-color: $light-gray
|
||||
color: $white
|
||||
|
||||
.storage-manager
|
||||
width: 70%
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1rem
|
||||
|
||||
.storage-manager .storage-row
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
|
||||
h3
|
||||
margin: 0
|
||||
color: $darker-white
|
||||
|
||||
.dialog-btn
|
||||
padding: 0.4rem 1rem
|
||||
|
||||
|
||||
@media (max-width: 781px)
|
||||
.storage-dialog
|
||||
width: 90vw
|
||||
margin-left: 0
|
||||
height: 100%
|
||||
margin-top: 0.5rem
|
||||
margin-right: 0
|
||||
flex-direction: column
|
||||
|
||||
.storage-bottom
|
||||
display: none
|
||||
|
||||
.storage-manager
|
||||
max-height: 15rem
|
||||
width: 100%
|
||||
|
||||
.storage-top
|
||||
flex-direction: row
|
||||
justify-content: center
|
||||
overflow-x: scroll
|
||||
overflow-y: hidden
|
||||
gap: 0.5rem
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, {useContext, useState} from "react";
|
||||
import {deleteRequest, downloadRequest, putRequest} from "@/common/utils/RequestUtil";
|
||||
import {DialogContext} from "@/common/contexts/Dialog";
|
||||
import {ToastNotificationContext} from "@/common/contexts/ToastNotification";
|
||||
import {ConfigContext} from "@/common/contexts/Config";
|
||||
import {t} from "i18next";
|
||||
import {faClockRotateLeft, faFileExport, faFileImport} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export default () => {
|
||||
const close = useContext(DialogContext);
|
||||
const [deleteWarning, setDeleteWarning] = useState(false);
|
||||
const updateConfig = useContext(ConfigContext)[1];
|
||||
const updateToast = useContext(ToastNotificationContext);
|
||||
|
||||
const exportConfig = () => {
|
||||
downloadRequest("/storage/config").then(() => {
|
||||
updateToast(t("storage.settings_exported"), "green", faFileExport);
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
const importConfig = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json";
|
||||
|
||||
input.onchange = () => {
|
||||
const file = input.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file);
|
||||
|
||||
reader.onload = () => {
|
||||
const data = JSON.parse(reader.result);
|
||||
putRequest("/storage/config", data).then((res) => {
|
||||
if (res.ok) {
|
||||
updateToast(t("storage.settings_imported"), "green", faFileImport);
|
||||
updateConfig();
|
||||
close();
|
||||
} else {
|
||||
updateToast(t("storage.import_error"), "red");
|
||||
}
|
||||
});
|
||||
}
|
||||
input.remove();
|
||||
}
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
const factoryReset = () => {
|
||||
if (!deleteWarning) {
|
||||
setDeleteWarning(true);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteRequest("/storage/config").then(() => {
|
||||
setDeleteWarning(false);
|
||||
updateToast(t("storage.factory_reset_completed"), "green", faClockRotateLeft);
|
||||
updateConfig();
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="storage-row">
|
||||
<h3>{t("storage.export_settings")}</h3>
|
||||
<button className="dialog-btn" onClick={exportConfig}>{t("storage.export")}</button>
|
||||
</div>
|
||||
|
||||
<div className="storage-row">
|
||||
<h3>{t("storage.import_settings")}</h3>
|
||||
<button className="dialog-btn" onClick={importConfig}>{t("storage.import")}</button>
|
||||
</div>
|
||||
|
||||
<div className="storage-row">
|
||||
<h3>{t("storage.factory_reset")}</h3>
|
||||
<button className="dialog-btn dialog-secondary" onClick={factoryReset}>
|
||||
{deleteWarning ? t("storage.confirm_reset") : t("storage.reset")}</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, {useContext, useState} from "react";
|
||||
import {DialogContext} from "@/common/contexts/Dialog";
|
||||
import {deleteRequest, downloadRequest, putRequest} from "@/common/utils/RequestUtil";
|
||||
import {SpeedtestContext} from "@/common/contexts/Speedtests";
|
||||
import {ToastNotificationContext} from "@/common/contexts/ToastNotification";
|
||||
import {t} from "i18next";
|
||||
import {faFileExport, faFileImport, faTrashCan} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export default ({tests}) => {
|
||||
const close = useContext(DialogContext);
|
||||
const [deleteWarning, setDeleteWarning] = useState(false);
|
||||
const updateTests = useContext(SpeedtestContext)[1];
|
||||
const updateToast = useContext(ToastNotificationContext);
|
||||
|
||||
const deleteHistory = () => {
|
||||
if (!deleteWarning) {
|
||||
setDeleteWarning(true);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteRequest("/storage/tests/history").then(() => {
|
||||
setDeleteWarning(false);
|
||||
updateTests();
|
||||
updateToast(t("storage.history_cleared"), "green", faTrashCan);
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
const downloadHistory = (type) => {
|
||||
downloadRequest(`/storage/tests/history/${type}`).then(() => {
|
||||
updateToast(t("storage.tests_exported"), "green", faFileExport);
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
const importHistory = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json";
|
||||
|
||||
input.onchange = () => {
|
||||
const file = input.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file);
|
||||
|
||||
reader.onload = () => {
|
||||
const data = JSON.parse(reader.result);
|
||||
putRequest("/storage/tests/history", data).then((res) => {
|
||||
if (res.ok) {
|
||||
updateToast(t("storage.tests_imported"), "green", faFileImport);
|
||||
updateTests();
|
||||
} else {
|
||||
updateToast(t("storage.import_error"), "red");
|
||||
}
|
||||
close();
|
||||
});
|
||||
}
|
||||
input.remove();
|
||||
}
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="storage-row">
|
||||
<h3>{t("storage.stored_tests")}</h3>
|
||||
<h3>{tests} {t("storage.tests")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="storage-row">
|
||||
<h3>{t("storage.export_tests")}</h3>
|
||||
<div>
|
||||
<button className="dialog-btn" onClick={() => downloadHistory("csv")}>
|
||||
{t("storage.csv")}</button>
|
||||
<button className="dialog-btn" onClick={() => downloadHistory("json")}>
|
||||
{t("storage.json")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="storage-row">
|
||||
<h3>{t("storage.import_tests")}</h3>
|
||||
<button className="dialog-btn" onClick={importHistory}>{t("storage.import")}</button>
|
||||
</div>
|
||||
|
||||
<div className="storage-row">
|
||||
<h3>{t("storage.clear_history")}</h3>
|
||||
<button className="dialog-btn dialog-secondary" onClick={deleteHistory}>
|
||||
{deleteWarning ? t("storage.confirm_delete") : t("storage.delete")}</button>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -17,9 +17,9 @@ export const Dialog = () => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [provider, setProvider] = useState("ookla");
|
||||
|
||||
const [ping, setPing] = useState(25);
|
||||
const [download, setDownload] = useState(100);
|
||||
const [upload, setUpload] = useState(50);
|
||||
const [ping, setPing] = useState(parseInt(config.ping) || 0);
|
||||
const [download, setDownload] = useState(parseInt(config.download) || 0);
|
||||
const [upload, setUpload] = useState(parseInt(config.upload) || 0);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const finish = async () => {
|
||||
|
||||
@@ -42,4 +42,19 @@
|
||||
|
||||
.speed-text p
|
||||
margin: 0
|
||||
color: $subtext
|
||||
color: $subtext
|
||||
|
||||
|
||||
@media screen and (max-width: 600px)
|
||||
.data-helper .speeds
|
||||
flex-direction: column
|
||||
gap: 1rem
|
||||
margin-top: 0.5rem
|
||||
|
||||
.speed
|
||||
width: 100%
|
||||
flex-direction: row
|
||||
justify-content: space-between
|
||||
|
||||
input
|
||||
width: 50%
|
||||
@@ -35,3 +35,11 @@
|
||||
to
|
||||
opacity: 1
|
||||
transform: translateX(0)
|
||||
|
||||
|
||||
@media screen and (max-width: 600px)
|
||||
.welcome-banner
|
||||
width: 100%
|
||||
|
||||
.slide-in
|
||||
animation: slide-in 0.5s forwards
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
body, html
|
||||
margin: 0
|
||||
overflow-x: hidden
|
||||
background-color: $background
|
||||
font-family: "Inter", sans-serif
|
||||
font-weight: 700
|
||||
|
||||
@@ -5,6 +5,7 @@ import App from './App';
|
||||
export const PROJECT_URL = "https://github.com/gnmyt/myspeed";
|
||||
export const WEB_URL = "https://myspeed.dev";
|
||||
export const PROJECT_WIKI = "https://docs.myspeed.dev";
|
||||
export const DONATION_URL = "https://ko-fi.com/gnmyt";
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
color: $white
|
||||
font-weight: 700
|
||||
font-size: 24pt
|
||||
white-space: nowrap
|
||||
|
||||
.container-subtext
|
||||
color: $subtext
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
const config = require("../models/Config");
|
||||
const node = require("../models/Node");
|
||||
const test = require("../models/Speedtests");
|
||||
const recommendations = require("../models/Recommendations");
|
||||
const integration = require("../models/IntegrationData");
|
||||
const {triggerEvent} = require("./integrations");
|
||||
const bcrypt = require('bcrypt');
|
||||
const timer = require('../tasks/timer');
|
||||
const cron = require('cron-validator');
|
||||
const db = require("../config/database");
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configDefaults = {
|
||||
ping: "25",
|
||||
@@ -38,4 +48,131 @@ module.exports.updateValue = async (key, newValue) => {
|
||||
.then(undefined);
|
||||
|
||||
return await config.update({value: newValue}, {where: {key: key}});
|
||||
}
|
||||
|
||||
module.exports.getUsedStorage = async () => {
|
||||
let size = 0;
|
||||
|
||||
if (process.env.DB_TYPE === "mysql") {
|
||||
const sizes = await db.query("SELECT table_name AS `Table`, ROUND((data_length + index_length), 2) AS `size` FROM information_schema.TABLES WHERE table_schema = ?;", {
|
||||
replacements: [process.env.DB_NAME],
|
||||
type: db.QueryTypes.SELECT
|
||||
});
|
||||
for (let i = 0; i < sizes.length; i++) {
|
||||
size += parseFloat(sizes[i].size);
|
||||
}
|
||||
} else {
|
||||
const STORAGE_PATH = `../../data/storage${process.env.PREVIEW_MODE === "true" ? "_preview" : ""}.db`;
|
||||
|
||||
size = fs.statSync(path.join(__dirname, STORAGE_PATH)).size;
|
||||
}
|
||||
|
||||
return {size, testCount: await test.count()};
|
||||
}
|
||||
|
||||
module.exports.validateInput = async (key, value) => {
|
||||
if (!value?.toString()) return "You need to provide the new value";
|
||||
|
||||
if ((key === "ping" || key === "download" || key === "upload") && isNaN(value))
|
||||
return "You need to provide a number in order to change this";
|
||||
|
||||
if ((key === "ooklaId" || key === "libreId") && (isNaN(value) && value !== "none"))
|
||||
return "You need to provide a number in order to change this";
|
||||
|
||||
if (key === "passwordLevel" && !["none", "read"].includes(value))
|
||||
return "You need to provide either none or read-access";
|
||||
|
||||
if (key === "provider" && !["ookla", "libre", "cloudflare"].includes(value))
|
||||
return "You need to provide a valid provider";
|
||||
|
||||
if (key === "ping")
|
||||
value = value.toString().split(".")[0];
|
||||
|
||||
if (key === "password" && value !== "none")
|
||||
value = await bcrypt.hash(value, 10);
|
||||
|
||||
if (key === "cron" && !cron.isValidCron(value.toString()))
|
||||
return "Not a valid cron expression";
|
||||
|
||||
if (configDefaults[key] === undefined)
|
||||
return "The provided key does not exist";
|
||||
|
||||
if (process.env.PREVIEW_MODE === "true" && (key === "password" || key === "passwordLevel"))
|
||||
return "You can't change the password in preview mode";
|
||||
|
||||
return {value: value};
|
||||
}
|
||||
|
||||
module.exports.exportConfig = async () => {
|
||||
let obj = {};
|
||||
obj.config = {};
|
||||
|
||||
let configValues = await config.findAll();
|
||||
for (let i = 0; i < configValues.length; i++) {
|
||||
if (configValues[i].key === "password") continue;
|
||||
obj.config[configValues[i].key] = configValues[i].value;
|
||||
}
|
||||
|
||||
obj.nodes = await node.findAll();
|
||||
obj.recommendations = await recommendations.findAll();
|
||||
|
||||
obj.integrations = await integration.findAll();
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
module.exports.importConfig = async (obj) => {
|
||||
let configValues = obj.config;
|
||||
for (let key in configValues) {
|
||||
if (configDefaults[key] === undefined) return false;
|
||||
if (key === "password") continue;
|
||||
|
||||
const validate = await this.validateInput(key, configValues[key]);
|
||||
if (Object.keys(validate).length !== 1) return false;
|
||||
|
||||
if (key === "cron") {
|
||||
timer.stopTimer();
|
||||
timer.startTimer(configValues[key].toString());
|
||||
}
|
||||
|
||||
await config.update({value: validate.value}, {where: {key: key}});
|
||||
}
|
||||
|
||||
if (recommendations.length > 1) return false;
|
||||
|
||||
await node.destroy({where: {}});
|
||||
await recommendations.destroy({where: {}});
|
||||
await integration.destroy({where: {}});
|
||||
|
||||
try {
|
||||
await node.bulkCreate(obj.nodes);
|
||||
|
||||
for (let i = 0; i < obj.integrations.length; i++) {
|
||||
obj.integrations[i].data = JSON.parse(obj.integrations[i].data);
|
||||
}
|
||||
|
||||
await integration.bulkCreate(obj.integrations);
|
||||
|
||||
await recommendations.bulkCreate(obj.recommendations);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports.factoryReset = async () => {
|
||||
let configValues = await config.findAll();
|
||||
for (let i = 0; i < configValues.length; i++) {
|
||||
await config.update({value: configDefaults[configValues[i].key]}, {where: {key: configValues[i].key}});
|
||||
}
|
||||
|
||||
await node.destroy({where: {}});
|
||||
await recommendations.destroy({where: {}});
|
||||
await integration.destroy({where: {}});
|
||||
|
||||
timer.stopTimer();
|
||||
timer.startTimer(configDefaults.cron);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -66,6 +66,25 @@ module.exports.listAverage = async (days) => {
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports.deleteTests = async () => {
|
||||
await tests.destroy({where: {}});
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports.importTests = async (data) => {
|
||||
if (!Array.isArray(data)) return false;
|
||||
|
||||
for (let entry of data) {
|
||||
if (entry.error === null) delete entry.error;
|
||||
try {
|
||||
await tests.create(entry);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -20,7 +20,7 @@ app.use(require('./middlewares/error'));
|
||||
app.use("/api/config", require('./routes/config'));
|
||||
app.use("/api/speedtests", require('./routes/speedtests'));
|
||||
app.use("/api/info", require('./routes/system'));
|
||||
app.use("/api/export", require('./routes/export'));
|
||||
app.use("/api/storage", require('./routes/storage'));
|
||||
app.use("/api/recommendations", require('./routes/recommendations'));
|
||||
app.use("/api/nodes", require('./routes/nodes'));
|
||||
app.use("/api/integrations", require('./routes/integrations'));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const app = require('express').Router();
|
||||
const config = require('../controller/config');
|
||||
const timer = require('../tasks/timer');
|
||||
const cron = require('cron-validator');
|
||||
const password = require('../middlewares/password');
|
||||
|
||||
app.get("/", password(true), async (req, res) => {
|
||||
@@ -21,34 +20,11 @@ app.get("/", password(true), async (req, res) => {
|
||||
});
|
||||
|
||||
app.patch("/:key", password(false), async (req, res) => {
|
||||
if (!req.body.value?.toString()) return res.status(400).json({message: "You need to provide the new value"});
|
||||
const value = await config.validateInput(req.params.key, req.body?.value);
|
||||
if (Object.keys(value).length !== 1) return res.status(400).json({message: value});
|
||||
|
||||
if ((req.params.key === "ping" || req.params.key === "download" || req.params.key === "upload") && isNaN(req.body.value))
|
||||
return res.status(400).json({message: "You need to provide a number in order to change this"});
|
||||
|
||||
if ((req.params.key === "ooklaId" || req.params.key === "libreId") && (isNaN(req.body.value) && req.body.value !== "none"))
|
||||
return res.status(400).json({message: "You need to provide a number in order to change this"});
|
||||
|
||||
|
||||
if (req.params.key === "passwordLevel" && !["none", "read"].includes(req.body.value))
|
||||
return res.status(400).json({message: "You need to provide either none or read-access"});
|
||||
|
||||
if (req.params.key === "provider" && !["ookla", "libre", "cloudflare"].includes(req.body.value))
|
||||
return res.status(400).json({message: "You need to provide a valid provider"});
|
||||
|
||||
if (req.params.key === "ping")
|
||||
req.body.value = req.body.value.toString().split(".")[0];
|
||||
|
||||
if (req.params.key === "password" && req.body.value !== "none") req.body.value = await require('bcrypt').hash(req.body.value, 10);
|
||||
|
||||
if (req.params.key === "cron" && !cron.isValidCron(req.body.value.toString()))
|
||||
return res.status(500).json({message: "Not a valid cron expression"});
|
||||
|
||||
if (!await config.updateValue(req.params.key, req.body.value.toString()))
|
||||
return res.status(404).json({message: "The provided key does not exist"});
|
||||
|
||||
if (process.env.PREVIEW_MODE === "true" && (req.params.key === "password" || req.params.key === "passwordLevel"))
|
||||
return res.status(403).json({message: "You can't change the password in preview mode"});
|
||||
if (!await config.updateValue(req.params.key, value.value))
|
||||
return res.status(500).json({message: `Error updating the key '${req.params.key}'`});
|
||||
|
||||
if (req.params.key === "cron") {
|
||||
timer.stopTimer();
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
const app = require('express').Router();
|
||||
const tests = require('../controller/speedtests');
|
||||
const password = require('../middlewares/password');
|
||||
|
||||
app.get("/json", password(false), async (req, res) => {
|
||||
res.set({"Content-Disposition": "attachment; filename=\"speedtests.json\""});
|
||||
res.send(JSON.stringify(await tests.listTests(), null, 4));
|
||||
});
|
||||
|
||||
app.get("/csv", password(false), async (req, res) => {
|
||||
res.set({"Content-Disposition": "attachment; filename=\"speedtests.csv\""});
|
||||
let list = await tests.listTests();
|
||||
|
||||
if (list.length === 0) return res.send("");
|
||||
let fields = Object.keys(list[0]);
|
||||
|
||||
let replacer = (key, value) => value === null ? '' : value;
|
||||
|
||||
let csv = list.map(row => fields.map(fieldName => JSON.stringify(row[fieldName], replacer)).join(','));
|
||||
csv.unshift('"' + fields.join('","') + '"');
|
||||
csv = csv.join('\r\n');
|
||||
|
||||
res.send(csv);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
56
server/routes/storage.js
Normal file
56
server/routes/storage.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const app = require('express').Router();
|
||||
const tests = require('../controller/speedtests');
|
||||
const config = require('../controller/config');
|
||||
const password = require('../middlewares/password');
|
||||
|
||||
app.get("/", password(false), async (req, res) => {
|
||||
res.json(await config.getUsedStorage());
|
||||
});
|
||||
|
||||
app.get("/tests/history/json", password(false), async (req, res) => {
|
||||
res.set({"Content-Disposition": "attachment; filename=\"speedtests.json\""});
|
||||
res.send(JSON.stringify(await tests.listTests(), null, 4));
|
||||
});
|
||||
|
||||
app.get("/tests/history/csv", password(false), async (req, res) => {
|
||||
res.set({"Content-Disposition": "attachment; filename=\"speedtests.csv\""});
|
||||
let list = await tests.listTests();
|
||||
|
||||
if (list.length === 0) return res.send("");
|
||||
let fields = Object.keys(list[0]);
|
||||
|
||||
let replacer = (key, value) => value === null ? '' : value;
|
||||
|
||||
let csv = list.map(row => fields.map(fieldName => JSON.stringify(row[fieldName], replacer)).join(','));
|
||||
csv.unshift('"' + fields.join('","') + '"');
|
||||
csv = csv.join('\r\n');
|
||||
|
||||
res.send(csv);
|
||||
});
|
||||
|
||||
app.delete("/tests/history", password(false), async (req, res) => {
|
||||
let result = await tests.deleteTests();
|
||||
res.status(result ? 200 : 500).json({message: result ? "Tests deleted" : "Error deleting tests"});
|
||||
});
|
||||
|
||||
app.put("/tests/history", password(false), async (req, res) => {
|
||||
let result = await tests.importTests(req.body);
|
||||
res.status(result ? 200 : 500).json({message: result ? "Tests imported" : "Error importing tests"});
|
||||
});
|
||||
|
||||
app.get("/config", password(false), async (req, res) => {
|
||||
res.set({"Content-Disposition": "attachment; filename=\"config.json\""});
|
||||
config.exportConfig().then(obj => res.json(obj));
|
||||
});
|
||||
|
||||
app.put("/config", password(false), async (req, res) => {
|
||||
let result = await config.importConfig(req.body);
|
||||
res.status(result ? 200 : 500).json({message: result ? "Config imported" : "Error importing config"});
|
||||
});
|
||||
|
||||
app.delete("/config", password(false), async (req, res) => {
|
||||
let result = await config.factoryReset();
|
||||
res.status(result ? 200 : 500).json({message: result ? "Config reset" : "Error resetting config"});
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
Reference in New Issue
Block a user