Merge pull request #681 from gnmyt/updates/storage-management

💾 Storage Management hinzugefügt
This commit is contained in:
Mathias Wagner
2024-05-20 17:03:24 +02:00
committed by GitHub
24 changed files with 638 additions and 93 deletions

View File

@@ -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>.",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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")
});

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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();
}

View 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>
)
}

View File

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

View 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

View File

@@ -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>
</>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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 () => {

View File

@@ -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%

View File

@@ -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

View File

@@ -2,6 +2,7 @@
body, html
margin: 0
overflow-x: hidden
background-color: $background
font-family: "Inter", sans-serif
font-weight: 700

View File

@@ -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'));

View File

@@ -41,6 +41,7 @@
color: $white
font-weight: 700
font-size: 24pt
white-space: nowrap
.container-subtext
color: $subtext

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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'));

View File

@@ -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();

View File

@@ -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
View 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;