├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report_de.yml │ ├── bug_report_en.yml │ ├── feature_request_de.yml │ └── feature_request_en.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── create_release.yml │ ├── deploy_docker_dev.yml │ ├── merge-dependabot.yml │ └── upload_docs.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.de.md ├── README.md ├── client ├── .gitignore ├── index.html ├── jsconfig.json ├── package-lock.json ├── package.json ├── public │ ├── assets │ │ ├── img │ │ │ ├── favicon.ico │ │ │ ├── logo.png │ │ │ └── logo192.png │ │ └── locales │ │ │ ├── bg.json │ │ │ ├── de.json │ │ │ ├── en.json │ │ │ ├── es.json │ │ │ ├── fr.json │ │ │ ├── it.json │ │ │ ├── nl.json │ │ │ ├── pt.json │ │ │ ├── ru.json │ │ │ ├── tr.json │ │ │ └── zh.json │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.jsx │ ├── common │ │ ├── assets │ │ │ ├── icons │ │ │ │ └── pushover.js │ │ │ └── languages │ │ │ │ ├── bg.webp │ │ │ │ ├── br.webp │ │ │ │ ├── de.webp │ │ │ │ ├── en.webp │ │ │ │ ├── es.webp │ │ │ │ ├── fr.webp │ │ │ │ ├── it.webp │ │ │ │ ├── nl.webp │ │ │ │ ├── ru.webp │ │ │ │ ├── tr.webp │ │ │ │ └── zh.webp │ │ ├── components │ │ │ ├── Dropdown │ │ │ │ ├── DropdownComponent.jsx │ │ │ │ ├── index.js │ │ │ │ ├── styles.sass │ │ │ │ └── utils │ │ │ │ │ ├── infos.jsx │ │ │ │ │ ├── options.js │ │ │ │ │ └── utils.jsx │ │ │ ├── Header │ │ │ │ ├── HeaderComponent.jsx │ │ │ │ ├── components │ │ │ │ │ └── Pagination │ │ │ │ │ │ ├── Pagination.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ ├── index.js │ │ │ │ ├── styles.sass │ │ │ │ └── utils │ │ │ │ │ └── infos.jsx │ │ │ ├── IntegrationDialog │ │ │ │ ├── IntegrationDialog.jsx │ │ │ │ ├── components │ │ │ │ │ ├── AvailableIntegrations │ │ │ │ │ │ ├── AvailableIntegrations.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ │ ├── IntegrationAddButton │ │ │ │ │ │ ├── IntegrationAddButton.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ │ ├── IntegrationItem │ │ │ │ │ │ ├── IntegrationItem.jsx │ │ │ │ │ │ ├── components │ │ │ │ │ │ │ └── IntegrationItemHeader │ │ │ │ │ │ │ │ ├── IntegrationItemHeader.jsx │ │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ │ └── styles.sass │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ │ └── NoIntegrationsTab │ │ │ │ │ │ ├── NoIntegrationsTab.jsx │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ └── styles.sass │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── LanguageDialog │ │ │ │ ├── LanguageDialog.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── LoadingDialog │ │ │ │ ├── LoadingDialog.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── ProviderDialog │ │ │ │ ├── ProviderDialog.jsx │ │ │ │ ├── assets │ │ │ │ │ └── img │ │ │ │ │ │ ├── cloudflare.webp │ │ │ │ │ │ ├── libre.webp │ │ │ │ │ │ └── ookla.webp │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── StorageDialog │ │ │ │ ├── StorageDialog.jsx │ │ │ │ ├── index.js │ │ │ │ ├── styles.sass │ │ │ │ └── tabs │ │ │ │ │ ├── Configuration.jsx │ │ │ │ │ └── Speedtests.jsx │ │ │ └── WelcomeDialog │ │ │ │ ├── WelcomeDialog.jsx │ │ │ │ ├── banner.webp │ │ │ │ ├── index.js │ │ │ │ ├── steps │ │ │ │ ├── DataHelper │ │ │ │ │ ├── DataHelper.jsx │ │ │ │ │ ├── index.js │ │ │ │ │ └── styles.sass │ │ │ │ ├── Greetings │ │ │ │ │ ├── Greetings.jsx │ │ │ │ │ ├── index.js │ │ │ │ │ └── styles.sass │ │ │ │ ├── OoklaLicense │ │ │ │ │ ├── OoklaLicense.jsx │ │ │ │ │ ├── index.js │ │ │ │ │ └── styles.sass │ │ │ │ └── ProviderChooser │ │ │ │ │ ├── ProviderChooser.jsx │ │ │ │ │ ├── index.js │ │ │ │ │ └── styles.sass │ │ │ │ └── styles.sass │ │ ├── contexts │ │ │ ├── Config │ │ │ │ ├── ConfigContext.jsx │ │ │ │ ├── dialog.jsx │ │ │ │ └── index.js │ │ │ ├── Dialog │ │ │ │ ├── DialogContext.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── InputDialog │ │ │ │ ├── InputDialog.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── Node │ │ │ │ ├── NodeContext.jsx │ │ │ │ └── index.js │ │ │ ├── Speedtests │ │ │ │ ├── SpeedtestContext.jsx │ │ │ │ └── index.js │ │ │ ├── Status │ │ │ │ ├── StatusContext.jsx │ │ │ │ └── index.js │ │ │ └── ToastNotification │ │ │ │ ├── ToastNotificationContext.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ ├── styles │ │ │ ├── _colors.sass │ │ │ ├── default.sass │ │ │ └── spinner.sass │ │ └── utils │ │ │ ├── RequestUtil.js │ │ │ └── TestUtil.js │ ├── i18n.js │ ├── index.jsx │ └── pages │ │ ├── Error │ │ ├── Error.jsx │ │ ├── index.js │ │ └── styles.sass │ │ ├── Home │ │ ├── Home.jsx │ │ ├── components │ │ │ ├── LatestTest │ │ │ │ ├── LatestTestComponent.jsx │ │ │ │ ├── index.js │ │ │ │ ├── styles.sass │ │ │ │ ├── utils.js │ │ │ │ └── utils │ │ │ │ │ └── dialogs.jsx │ │ │ ├── Speedtest │ │ │ │ ├── SpeedtestComponent.jsx │ │ │ │ ├── index.js │ │ │ │ ├── styles.sass │ │ │ │ └── utils │ │ │ │ │ ├── errors.js │ │ │ │ │ └── infos.jsx │ │ │ └── TestArea │ │ │ │ ├── TestAreaComponent.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ └── index.js │ │ ├── Loading │ │ ├── Loading.jsx │ │ ├── index.js │ │ └── styles.sass │ │ ├── Nodes │ │ ├── Nodes.jsx │ │ ├── components │ │ │ ├── CreateNodeDialog │ │ │ │ ├── CreateNodeDialog.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ ├── NodeContainer │ │ │ │ ├── NodeContainer.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ │ └── NodeHeader │ │ │ │ ├── NodeHeader.jsx │ │ │ │ ├── index.js │ │ │ │ └── styles.sass │ │ ├── index.js │ │ └── styles.sass │ │ └── Statistics │ │ ├── Statistics.jsx │ │ ├── charts │ │ ├── AverageChart │ │ │ ├── AverageChart.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ │ ├── DurationChart.jsx │ │ ├── FailedChart.jsx │ │ ├── LatestTestChart │ │ │ ├── LatestTestChart.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ │ ├── ManualChart.jsx │ │ ├── OverviewChart │ │ │ ├── OverviewChart.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ │ ├── PingChart.jsx │ │ └── SpeedChart.jsx │ │ ├── components │ │ └── StatisticContainer │ │ │ ├── StatisticContainer.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ │ ├── index.js │ │ └── styles.sass └── vite.config.js ├── crowdin.yml ├── docs ├── CNAME ├── assets │ └── images │ │ ├── de │ │ ├── integrations.png │ │ ├── interface.png │ │ ├── latest.png │ │ ├── settings.png │ │ ├── tests.png │ │ └── view.png │ │ ├── en │ │ ├── integrations.png │ │ ├── interface.png │ │ ├── latest.png │ │ ├── settings.png │ │ ├── tests.png │ │ └── view.png │ │ ├── latest_test.png │ │ └── logo.png ├── faq.de.md ├── faq.en.md ├── guides │ ├── reverse-proxy.de.md │ └── reverse-proxy.en.md ├── index.de.md ├── index.en.md ├── instructions │ ├── main.de.md │ ├── main.en.md │ ├── settings.de.md │ └── settings.en.md ├── setup │ ├── linux.de.md │ ├── linux.en.md │ ├── windows.de.md │ └── windows.en.md ├── troubleshooting.de.md └── troubleshooting.en.md ├── mkdocs.yml ├── package-lock.json ├── package.json ├── scripts ├── chooser.sh ├── docker-install.sh ├── install.sh └── uninstall.sh ├── server ├── config │ ├── binaries.js │ └── database.js ├── controller │ ├── config.js │ ├── integrations.js │ ├── node.js │ ├── opengraph.js │ ├── pause.js │ ├── recommendations.js │ ├── servers.js │ └── speedtests.js ├── index.js ├── integrations │ ├── discord.js │ ├── gotify.js │ ├── healthChecks.js │ ├── pushover.js │ ├── telegram.js │ └── webhook.js ├── middlewares │ ├── error.js │ ├── password.js │ └── passwordWrapper.js ├── models │ ├── Config.js │ ├── IntegrationData.js │ ├── Node.js │ ├── Recommendations.js │ └── Speedtests.js ├── routes │ ├── config.js │ ├── integrations.js │ ├── nodes.js │ ├── opengraph.js │ ├── prometheus.js │ ├── recommendations.js │ ├── speedtests.js │ ├── storage.js │ └── system.js ├── tasks │ ├── cloudflare.js │ ├── integrations.js │ ├── speedtest.js │ └── timer.js ├── templates │ └── env.html └── util │ ├── createFolders.js │ ├── errorHandler.js │ ├── helpers.js │ ├── loadCli.js │ ├── loadInterfaces.js │ ├── loadServers.js │ ├── providers │ ├── loadLibre.js │ ├── loadOokla.js │ └── parseData.js │ └── speedtest.js └── web ├── .eslintrc.cjs ├── .gitignore ├── index.html ├── jsconfig.json ├── package-lock.json ├── package.json ├── public ├── assets │ ├── fonts │ │ ├── inter-v12-latin-300.ttf │ │ ├── inter-v12-latin-300.woff2 │ │ ├── inter-v12-latin-500.ttf │ │ ├── inter-v12-latin-500.woff2 │ │ ├── inter-v12-latin-700.ttf │ │ ├── inter-v12-latin-700.woff2 │ │ ├── inter-v12-latin-900.ttf │ │ ├── inter-v12-latin-900.woff2 │ │ ├── inter-v12-latin-regular.ttf │ │ └── inter-v12-latin-regular.woff2 │ └── img │ │ ├── favicon.ico │ │ ├── logo.png │ │ └── logo192.png └── robots.txt ├── src ├── common │ ├── assets │ │ ├── background.png │ │ ├── feature │ │ │ ├── cron.png │ │ │ ├── integrations.png │ │ │ ├── language.png │ │ │ └── views.png │ │ ├── interface.png │ │ ├── logo192.png │ │ ├── logo_docs.png │ │ ├── not_found.svg │ │ ├── sc1.png │ │ └── sc2.png │ ├── components │ │ ├── Button │ │ │ ├── Button.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ │ └── Navigation │ │ │ ├── Navigation.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ ├── layouts │ │ └── Root │ │ │ ├── Root.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ └── styles │ │ ├── _colors.sass │ │ ├── default.sass │ │ └── fonts.sass ├── main.jsx └── pages │ ├── Home │ ├── Home.jsx │ ├── components │ │ ├── FeatureGrid │ │ │ ├── FeatureGrid.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ │ ├── Features │ │ │ ├── Features.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ │ ├── Footer │ │ │ ├── Footer.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ │ └── GetStarted │ │ │ ├── GetStarted.jsx │ │ │ ├── index.js │ │ │ └── styles.sass │ ├── index.js │ └── styles.sass │ ├── Imprint │ ├── Imprint.jsx │ ├── index.js │ └── styles.sass │ ├── Install │ ├── Install.jsx │ ├── index.js │ └── styles.sass │ ├── NotFound │ ├── NotFound.jsx │ ├── index.js │ └── styles.sass │ ├── Privacy │ ├── Privacy.jsx │ ├── index.js │ └── styles.sass │ ├── TutorialSubmission │ ├── TutorialSubmission.jsx │ ├── index.js │ └── styles.sass │ └── Tutorials │ ├── Tutorials.jsx │ ├── index.js │ ├── sources │ ├── blog_posts.jsx │ ├── channels │ │ ├── addrom.webp │ │ ├── belginux.webp │ │ ├── bigbeartechworld.png │ │ ├── dbtech.webp │ │ ├── gigazine.png │ │ ├── linuxiac.webp │ │ ├── mariushosting.png │ │ ├── pavl21.png │ │ ├── retromiketech.png │ │ └── ubunlog.png │ ├── thumbs │ │ ├── 20240128.webp │ │ ├── 7108075382452079878.webp │ │ ├── 7roj87Fytz0.webp │ │ ├── Iic14oUCCVo.webp │ │ ├── MFbeWdKesTE.webp │ │ ├── SM3RJRktwIk.webp │ │ ├── ZIIa6yF-Tvo.webp │ │ ├── addrom-myspeed.webp │ │ ├── belginux-myspeed.webp │ │ ├── linuxiac-myspeed.webp │ │ ├── marius-myspeed.webp │ │ ├── tBJmhgn3ZOM.webp │ │ └── ubunlog-myspeed.webp │ └── videos.jsx │ └── styles.sass └── vite.config.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: gnmyt 2 | ko_fi: gnmyt -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report_de.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Fehler melden 2 | description: Melde einen Fehler oder Bug in MySpeed (🇩🇪) 3 | title: "[Fehler] " 4 | labels: ["bug"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Allgemeines 9 | description: Bitte bestätige, dass die Folgenden aussagen zutreffen 10 | options: 11 | - label: Ich habe auf die neuste Version von MySpeed aktualisiert. 12 | required: true 13 | - label: Mein Bug wurde noch nicht gemeldet 14 | required: true 15 | - type: textarea 16 | attributes: 17 | label: Der Fehler 18 | description: Beschreibe den Bug/Fehler genau ins Detail. Füge falls vorhanden auch Screenshots hinzu und gib an, wie man den Fehler reproduzieren kann 19 | placeholder: Es erscheint ein Fehler bei ... 20 | validations: 21 | required: true 22 | - type: dropdown 23 | id: browsers 24 | attributes: 25 | label: Auf welchem Gerät rufst du die Seite auf? 26 | multiple: true 27 | options: 28 | - Im Browser 29 | - Auf dem Handy 30 | - Auf einem Tablet 31 | validations: 32 | required: true 33 | - type: dropdown 34 | id: server 35 | attributes: 36 | label: Auf welchem Betriebssystem läuft deine MySpeed-Instanz? 37 | multiple: true 38 | options: 39 | - Linux 40 | - Windows 41 | - macOS 42 | validations: 43 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report_en.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Report a Bug 2 | description: Report a bug or issue in MySpeed (🇬🇧) 3 | title: "[Bug] " 4 | labels: ["bug"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: General 9 | description: Please confirm the following statements 10 | options: 11 | - label: I have updated to the latest version of MySpeed. 12 | required: true 13 | - label: My bug has not been reported yet. 14 | required: true 15 | - type: textarea 16 | attributes: 17 | label: The Bug 18 | description: Describe the bug/issue in detail. If possible, include screenshots and provide steps to reproduce the error. 19 | placeholder: An error occurs when ... 20 | validations: 21 | required: true 22 | - type: dropdown 23 | id: browsers 24 | attributes: 25 | label: What device are you using to access the page? 26 | multiple: true 27 | options: 28 | - In the browser 29 | - On mobile 30 | - On a tablet 31 | validations: 32 | required: true 33 | - type: dropdown 34 | id: server 35 | attributes: 36 | label: Which operating system is your MySpeed instance running on? 37 | multiple: true 38 | options: 39 | - Linux 40 | - Windows 41 | - macOS 42 | validations: 43 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request_de.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Idee vorschlagen 2 | description: Hast du eine Idee? Hier bist du richtig (🇩🇪) 3 | title: "[Feature] " 4 | labels: ["enhancement"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Allgemeines 9 | description: Bitte bestätige, dass die Folgenden aussagen zutreffen 10 | options: 11 | - label: Mein Feature existiert noch nicht in der neusten Version von MySpeed. 12 | required: true 13 | - label: Ich habe überprüft, dass mein Feature noch von niemanden vorgeschlagen wurde. 14 | required: true 15 | - type: textarea 16 | attributes: 17 | label: Deine Idee 18 | description: Was können wir hinzufügen? Beschreibe deine Idee so genau wie möglich 19 | placeholder: Meine Idee wäre es, ... 20 | validations: 21 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request_en.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Suggest idea 2 | description: Do you have an idea? You're in the right place (🇬🇧) 3 | title: "[Feature] " 4 | labels: ["enhancement"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: General 9 | description: Please confirm the following statements 10 | options: 11 | - label: My feature does not exist in the latest version of MySpeed. 12 | required: true 13 | - label: I have checked that my feature has not been suggested by anyone else. 14 | required: true 15 | - type: textarea 16 | attributes: 17 | label: Your Idea 18 | description: What can we add? Describe your idea as precisely as possible. 19 | placeholder: My idea would be to ... 20 | validations: 21 | required: true -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 📋 Description 2 | 3 | Please include a summary of the changes and the related issue. Explain the problem that you are solving and provide the 4 | necessary context. 5 | 6 | ## 🚀 Changes made to ... 7 | 8 | - [ ] 🔧 Server 9 | - [ ] 🖥️ Client 10 | - [ ] 📚 Documentation 11 | - [ ] 🔄 Other: ___ 12 | 13 | ## ✅ Checklist 14 | 15 | - [ ] My code follows the style guidelines of this project 16 | - [ ] I have performed a self-review of my own code 17 | - [ ] I have looked for similar pull requests in the repository and found none 18 | 19 | ## 🔗 Related Issues 20 | 21 | Fixes #(issue) 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | - package-ecosystem: 'npm' 8 | directory: '/client' 9 | schedule: 10 | interval: 'daily' -------------------------------------------------------------------------------- /.github/workflows/create_release.yml: -------------------------------------------------------------------------------- 1 | name: Upload the latest release 2 | 3 | on: 4 | push: 5 | tags: [ "v*" ] 6 | 7 | jobs: 8 | create: 9 | name: "Creates the newest release by version" 10 | runs-on: "ubuntu-latest" 11 | 12 | steps: 13 | - name: Checkout project 14 | uses: actions/checkout@v2.3.4 15 | 16 | - name: Setup node 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '16' 20 | 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@master 23 | with: 24 | platforms: all 25 | 26 | - name: Set up Docker Build 27 | uses: docker/setup-buildx-action@v2 28 | 29 | - name: Login to DockerHub 30 | uses: docker/login-action@v2 31 | with: 32 | username: ${{ secrets.DOCKERHUB_USERNAME }} 33 | password: ${{ secrets.DOCKERHUB_TOKEN }} 34 | 35 | - run: cd client && npm install --force 36 | - run: npm run build && mv client/build . 37 | 38 | - name: Get version 39 | id: get_version 40 | run: echo "::set-output name=version::$(jq .version package.json --raw-output)" 41 | 42 | - name: Install zip 43 | run: sudo apt-get install zip 44 | 45 | - name: Zip all files 46 | run: zip -r MySpeed-${{ steps.get_version.outputs.version }}.zip build server package.json package-lock.json 47 | 48 | - uses: "marvinpinto/action-automatic-releases@latest" 49 | with: 50 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 51 | prerelease: false 52 | title: Release ${{ steps.get_version.outputs.version }} 53 | files: | 54 | ./MySpeed-*.zip 55 | 56 | - name: Build and push 57 | uses: docker/build-push-action@v3 58 | with: 59 | push: true 60 | platforms: linux/amd64,linux/arm64,linux/arm/v7 61 | tags: | 62 | germannewsmaker/myspeed:latest 63 | germannewsmaker/myspeed:${{ steps.get_version.outputs.version }} -------------------------------------------------------------------------------- /.github/workflows/deploy_docker_dev.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Development Release to DockerHub 2 | 3 | on: 4 | push: 5 | branches: [ "development" ] 6 | 7 | jobs: 8 | docker: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Set up QEMU 15 | uses: docker/setup-qemu-action@master 16 | with: 17 | platforms: all 18 | 19 | - name: Set up Docker Build 20 | uses: docker/setup-buildx-action@v2 21 | 22 | - name: Login to DockerHub 23 | uses: docker/login-action@v2 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: Build and push 29 | uses: docker/build-push-action@v3 30 | with: 31 | push: true 32 | platforms: linux/amd64,linux/arm64,linux/arm/v7 33 | tags: germannewsmaker/myspeed:development -------------------------------------------------------------------------------- /.github/workflows/merge-dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-approve 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'gnmyt/myspeed' 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Approve a PR 19 | run: gh pr merge --auto --merge "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/upload_docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy documentation to GitHub 2 | on: 3 | push: 4 | branches: [ "development" ] 5 | paths: [ "docs/**", "mkdocs.yml" ] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - uses: actions/setup-python@v4 14 | with: 15 | python-version: 3.9 16 | 17 | - name: Install dependencies 18 | run: pip install mkdocs-material mkdocs-static-i18n 19 | 20 | - name: Deploy documentation 21 | run: mkdocs gh-deploy --force -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # code editors 2 | /.idea 3 | 4 | # dependencies 5 | /node_modules 6 | /client/node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | 11 | # production 12 | /build 13 | /data 14 | /bin -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS build 2 | RUN apk add --no-cache g++ make cmake python3 py3-setuptools 3 | 4 | WORKDIR /myspeed 5 | 6 | COPY ./client ./client 7 | COPY ./server ./server 8 | COPY ./package.json ./package.json 9 | 10 | RUN yarn install 11 | RUN cd client && yarn install --force 12 | RUN npm run build 13 | RUN mv /myspeed/client/build /myspeed 14 | 15 | FROM node:22-alpine 16 | 17 | RUN apk add --no-cache tzdata 18 | 19 | ENV NODE_ENV=production 20 | ENV TZ=Etc/UTC 21 | 22 | WORKDIR /myspeed 23 | 24 | COPY --from=build /myspeed/build /myspeed/build 25 | COPY --from=build /myspeed/server /myspeed/server 26 | COPY --from=build /myspeed/node_modules /myspeed/node_modules 27 | COPY --from=build /myspeed/package.json /myspeed/package.json 28 | 29 | VOLUME ["/myspeed/data"] 30 | 31 | EXPOSE 5216 32 | 33 | CMD ["node", "server"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mathias Wagner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | MySpeed 18 | 19 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /client/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "1.0.9", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "preview": "vite preview" 8 | }, 9 | "dependencies": { 10 | "@fontsource/inter": "^5.2.5", 11 | "@fortawesome/fontawesome-svg-core": "^6.7.2", 12 | "@fortawesome/free-brands-svg-icons": "^6.7.2", 13 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 14 | "@fortawesome/react-fontawesome": "^0.2.2", 15 | "@vitejs/plugin-react": "^4.5.0", 16 | "chart.js": "^4.4.9", 17 | "cron-parser": "^5.2.0", 18 | "i18next": "^25.2.1", 19 | "i18next-browser-languagedetector": "^8.1.0", 20 | "i18next-http-backend": "^3.0.2", 21 | "react": "^19.1.0", 22 | "react-chartjs-2": "^5.3.0", 23 | "react-dom": "^19.1.0", 24 | "react-i18next": "^15.5.2", 25 | "react-router-dom": "^7.6.1", 26 | "sass": "^1.89.1", 27 | "uuid": "^11.1.0", 28 | "vite": "^6.3.5", 29 | "vite-plugin-pwa": "^1.0.0" 30 | }, 31 | "devDependencies": { 32 | "@types/react": "^19.1.6", 33 | "@types/react-dom": "^19.1.5" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/public/assets/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/public/assets/img/favicon.ico -------------------------------------------------------------------------------- /client/public/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/public/assets/img/logo.png -------------------------------------------------------------------------------- /client/public/assets/img/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/public/assets/img/logo192.png -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "MySpeed", 3 | "name": "MySpeed - Speedtests", 4 | "description": "A speed test analysis software that shows your internet speed for up to 30 days", 5 | "icons": [ 6 | { 7 | "src": "/assets/img/favicon.ico", 8 | "sizes": "64x64 32x32 24x24 16x16", 9 | "type": "image/x-icon" 10 | }, 11 | { 12 | "src": "/assets/img/logo192.png", 13 | "type": "image/png", 14 | "sizes": "192x192", 15 | "purpose": "maskable" 16 | }, 17 | { 18 | "src": "/assets/img/logo.png", 19 | "type": "image/png", 20 | "sizes": "512x512" 21 | } 22 | ], 23 | "start_url": ".", 24 | "display": "standalone", 25 | "theme_color": "#232835", 26 | "background_color": "#232835" 27 | } 28 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /client/src/common/assets/languages/bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/assets/languages/bg.webp -------------------------------------------------------------------------------- /client/src/common/assets/languages/br.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/assets/languages/br.webp -------------------------------------------------------------------------------- /client/src/common/assets/languages/de.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/assets/languages/de.webp -------------------------------------------------------------------------------- /client/src/common/assets/languages/en.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/assets/languages/en.webp -------------------------------------------------------------------------------- /client/src/common/assets/languages/es.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/assets/languages/es.webp -------------------------------------------------------------------------------- /client/src/common/assets/languages/fr.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/assets/languages/fr.webp -------------------------------------------------------------------------------- /client/src/common/assets/languages/it.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/assets/languages/it.webp -------------------------------------------------------------------------------- /client/src/common/assets/languages/nl.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/assets/languages/nl.webp -------------------------------------------------------------------------------- /client/src/common/assets/languages/ru.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/assets/languages/ru.webp -------------------------------------------------------------------------------- /client/src/common/assets/languages/tr.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/assets/languages/tr.webp -------------------------------------------------------------------------------- /client/src/common/assets/languages/zh.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/assets/languages/zh.webp -------------------------------------------------------------------------------- /client/src/common/components/Dropdown/index.js: -------------------------------------------------------------------------------- 1 | export {default} from './DropdownComponent'; -------------------------------------------------------------------------------- /client/src/common/components/Dropdown/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .dropdown 4 | visibility: visible 5 | opacity: 1 6 | transition: opacity 0.1s linear 7 | user-select: none 8 | 9 | .dropdown-content 10 | float: right 11 | margin-right: 10% 12 | display: inline-block 13 | position: absolute 14 | width: auto 15 | overflow: auto 16 | border-radius: 10px 17 | box-shadow: 0 8px 16px 0 $dark-gray 18 | border: 1px solid $light-gray 19 | right: 0 20 | z-index: 1 21 | padding: 15px 22 | background-color: $dark-gray 23 | 24 | .dropdown-invisible 25 | visibility: hidden 26 | opacity: 0 27 | transition: visibility 0s 0.1s, opacity 0.1s linear 28 | 29 | .dropdown-content h2 30 | color: $subtext 31 | margin: 0 32 | font-size: 16pt 33 | 34 | .dropdown-hr 35 | border: 1px solid $light-gray 36 | margin: 5px 37 | width: 30px 38 | 39 | .dropdown-entries 40 | margin-top: 10px 41 | display: flex 42 | flex-direction: column 43 | 44 | .dropdown-item 45 | margin: 5px 10px 46 | display: flex 47 | align-items: center 48 | cursor: pointer 49 | color: $white 50 | 51 | .dropdown-item * 52 | margin: 0 53 | font-size: 16pt 54 | font-weight: 500 55 | 56 | .dropdown-item:hover 57 | color: $green 58 | 59 | .dropdown-item svg 60 | width: 25px 61 | height: 25px 62 | 63 | .dropdown-item h3 64 | margin-left: 15px 65 | 66 | .center 67 | display: flex 68 | justify-content: center 69 | 70 | @media (max-width: 390px) 71 | .dropdown-content 72 | margin-right: 0 -------------------------------------------------------------------------------- /client/src/common/components/Dropdown/utils/infos.jsx: -------------------------------------------------------------------------------- 1 | import {DONATION_URL, PROJECT_URL, WEB_URL} from "@/index"; 2 | import {Trans} from "react-i18next"; 3 | 4 | 5 | export const creditsInfo = () => , 6 | Github: , Donate: }}>info.credits 7 | 8 | export const recommendationsInfo = (ping, down, up) => }} 9 | values={{ping, down, up}}>info.recommendations_info -------------------------------------------------------------------------------- /client/src/common/components/Dropdown/utils/options.js: -------------------------------------------------------------------------------- 1 | import {t} from "i18next"; 2 | 3 | export const timeOptions = () => ({ 4 | 1: t("options.time.24hours"), 5 | 2: t("options.time.2days"), 6 | 3: t("options.time.7days"), 7 | 4: t("options.time.30days") 8 | }); 9 | 10 | export const levelOptions = () => ({ 11 | "none": t("options.level.no_access"), 12 | "read": t("options.level.read_access") 13 | }); 14 | 15 | export const selectOptions = () => ({ 16 | "* * * * *": t("options.cron.continuous"), 17 | "0,30 * * * *": t("options.cron.frequent"), 18 | "0 * * * *": t("options.cron.default"), 19 | "0 0,3,6,9,12,15,18,21 * * *": t("options.cron.rare"), 20 | "0 0,6,12,18 * * *": t("options.cron.really_rare") 21 | }); -------------------------------------------------------------------------------- /client/src/common/components/Dropdown/utils/utils.jsx: -------------------------------------------------------------------------------- 1 | import {parseExpression} from "cron-parser"; 2 | import {t} from "i18next"; 3 | 4 | // Parses cron as a locale string 5 | export const parseCron = (cron) => { 6 | try { 7 | return parseExpression(cron).next().toDate().toLocaleString() 8 | } catch (e) { 9 | return {t("dropdown.invalid")}; 10 | } 11 | } 12 | 13 | // Fixes the cron provided by the user 14 | export const stringifyCron = (cron) => { 15 | try { 16 | return parseExpression(cron).stringify(); 17 | } catch (e) { 18 | return null; 19 | } 20 | } -------------------------------------------------------------------------------- /client/src/common/components/Header/components/Pagination/index.js: -------------------------------------------------------------------------------- 1 | export {Pagination as default} from "./Pagination"; -------------------------------------------------------------------------------- /client/src/common/components/Header/components/Pagination/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .pagination 4 | display: flex 5 | justify-content: center 6 | background-color: $dark-gray 7 | border: 2px solid $light-gray 8 | padding: 0.7rem 0.8rem 9 | gap: 1rem 10 | border-radius: 1rem 11 | position: relative 12 | overflow: hidden 13 | box-sizing: border-box 14 | user-select: none 15 | 16 | .pagination-item 17 | display: flex 18 | color: $subtext 19 | justify-content: center 20 | align-items: center 21 | cursor: pointer 22 | padding: 0.5rem 1.5rem 23 | gap: 1rem 24 | border-radius: 0.5rem 25 | position: relative 26 | z-index: 1 27 | 28 | &:hover 29 | color: $white 30 | 31 | p 32 | margin: 0 33 | font-weight: 500 34 | font-size: 16pt 35 | svg 36 | font-size: 16pt 37 | 38 | 39 | 40 | .page-active 41 | color: $white 42 | 43 | .pagination-active-background 44 | position: absolute 45 | top: 50% 46 | left: var(--active-left, 0) 47 | width: var(--active-width, 0) 48 | height: calc(100% - 1rem) 49 | background-color: $light-gray 50 | border-radius: 0.8rem 51 | transition: left 0.3s ease, width 0.3s ease 52 | transform: translateY(-50%) 53 | z-index: 0 54 | 55 | 56 | @media (max-width: 968px) 57 | .pagination-item 58 | gap: 0.5rem 59 | 60 | p 61 | display: none 62 | 63 | 64 | @media (max-width: 768px) 65 | .pagination 66 | position: fixed 67 | bottom: 1rem 68 | left: 50% 69 | transform: translateX(-50%) 70 | z-index: 100 71 | box-shadow: 0 0 10px 0 $light-gray -------------------------------------------------------------------------------- /client/src/common/components/Header/index.js: -------------------------------------------------------------------------------- 1 | export {default} from './HeaderComponent'; -------------------------------------------------------------------------------- /client/src/common/components/Header/utils/infos.jsx: -------------------------------------------------------------------------------- 1 | import {Trans} from "react-i18next"; 2 | import {PROJECT_URL, PROJECT_WIKI} from "@/index"; 3 | 4 | export const updateInfo = (version) => , 5 | DLLink: }}>info.update -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/AvailableIntegrations/AvailableIntegrations.jsx: -------------------------------------------------------------------------------- 1 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 2 | import React from "react"; 3 | import {t} from "i18next"; 4 | import "./styles.sass"; 5 | 6 | export const AvailableIntegrations = ({integrations, currentTab, setCurrentTab}) => ( 7 |
8 | {Object.keys(integrations).map((key) =>
setCurrentTab(key)}> 11 | 12 | 13 |

{t(`integrations.${key}.title`)}

14 |
)} 15 |
16 | ) -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/AvailableIntegrations/index.js: -------------------------------------------------------------------------------- 1 | export {AvailableIntegrations as default} from './AvailableIntegrations'; -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/AvailableIntegrations/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .available-integrations 4 | display: flex 5 | flex-direction: column 6 | gap: 0.5rem 7 | user-select: none 8 | overflow-x: hidden 9 | overflow-y: scroll 10 | 11 | .integration-tab 12 | display: flex 13 | align-items: center 14 | gap: 0.5rem 15 | color: $subtext 16 | padding: 0.6rem 0.7rem 17 | border: 2px solid transparent 18 | border-radius: 1rem 19 | cursor: pointer 20 | 21 | svg 22 | width: 1.3rem 23 | height: 1.3rem 24 | p 25 | margin: 0 26 | font-size: 14pt 27 | 28 | .integration-active 29 | background-color: $light-gray 30 | color: $white 31 | 32 | @media (max-width: 781px) 33 | .available-integrations 34 | flex-direction: row 35 | overflow-x: scroll 36 | overflow-y: hidden 37 | gap: 0.5rem -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/IntegrationAddButton/IntegrationAddButton.jsx: -------------------------------------------------------------------------------- 1 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 2 | import {faAdd} from "@fortawesome/free-solid-svg-icons"; 3 | import {t} from "i18next"; 4 | import React from "react"; 5 | import "./styles.sass"; 6 | 7 | export const IntegrationAddButton = ({onClick}) => ( 8 |
9 |
10 | 11 |

{t("integrations.create")}

12 |
13 |
14 | ) -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/IntegrationAddButton/index.js: -------------------------------------------------------------------------------- 1 | export {IntegrationAddButton as default} from "./IntegrationAddButton"; -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/IntegrationAddButton/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .add-container 4 | width: 100% 5 | display: flex 6 | justify-content: flex-end 7 | 8 | .add-integration 9 | color: $subtext 10 | cursor: pointer 11 | border-radius: 0.8rem 12 | display: flex 13 | align-items: center 14 | justify-content: center 15 | gap: 0.5rem 16 | padding: 0.7rem 0.8rem 17 | user-select: none 18 | 19 | p 20 | margin: 0 21 | 22 | &:hover 23 | background-color: $light-gray -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/IntegrationItem/components/IntegrationItemHeader/index.js: -------------------------------------------------------------------------------- 1 | export {IntegrationItemHeader as default} from "./IntegrationItemHeader"; -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/IntegrationItem/components/IntegrationItemHeader/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .integration-item-header 4 | display: flex 5 | justify-content: space-between 6 | 7 | .integration-item-left 8 | display: flex 9 | align-items: center 10 | gap: 0.5rem 11 | 12 | svg 13 | width: 1.8rem 14 | height: 1.8rem 15 | 16 | .integration-title-container h3 17 | margin: 0 18 | font-size: 17px 19 | text-overflow: ellipsis 20 | overflow: hidden 21 | white-space: nowrap 22 | max-width: 11rem 23 | 24 | .integration-item-activity 25 | display: flex 26 | align-items: center 27 | gap: 0.3rem 28 | .integration-item-activity-circle 29 | width: 0.5rem 30 | height: 0.5rem 31 | border-radius: 5rem 32 | p 33 | margin: 0 34 | font-size: 11pt 35 | font-weight: 500 36 | 37 | .circle-error 38 | background-color: $red 39 | 40 | .circle-inactive 41 | background-color: $dark-gray 42 | 43 | .circle-active 44 | background-color: $green 45 | 46 | .integration-item-right 47 | display: flex 48 | align-items: center 49 | gap: 0.8rem 50 | 51 | .integration-item-right svg 52 | width: 1.5rem 53 | height: 3rem 54 | cursor: pointer 55 | font-weight: 1000 56 | 57 | .integration-green:hover 58 | color: $green 59 | 60 | .integration-red:hover 61 | color: $red -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/IntegrationItem/index.js: -------------------------------------------------------------------------------- 1 | export {IntegrationItem as default} from "./IntegrationItem"; -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/IntegrationItem/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .integration-item 4 | border: 2px solid $light-gray 5 | border-radius: 0.8rem 6 | padding: 0.5rem 1.5rem 7 | color: $subtext 8 | transition: border-color 0.3s 9 | 10 | .green-border 11 | border: 2px solid $green 12 | 13 | .error-border 14 | border: 2px solid $red 15 | 16 | .integration-body 17 | margin-left: 0.5rem 18 | margin-right: 0.5rem 19 | 20 | .integration-field 21 | display: flex 22 | align-items: center 23 | gap: 0.5rem 24 | margin-top: 0.5rem 25 | justify-content: space-between 26 | 27 | p 28 | margin: 0 29 | 30 | .error-item 31 | color: $red 32 | 33 | .integration-field-input 34 | background-color: $darker-gray 35 | border: 1px solid $light-gray 36 | border-radius: 0.5rem 37 | padding: 0.1rem 0.8rem 38 | color: $subtext 39 | font-size: 12pt 40 | height: 2rem 41 | width: 50% 42 | 43 | .item-error-border 44 | border: 1px solid $red 45 | 46 | .text-area 47 | font-family: "Inter", sans-serif 48 | height: 3.5rem 49 | resize: none 50 | 51 | input[type="checkbox"] 52 | appearance: none 53 | border: 1px solid $light-gray 54 | width: 2rem 55 | height: 2rem 56 | border-radius: 0.5rem 57 | outline: none 58 | transition: border-color 0.3s 59 | background-color: $darker-gray 60 | cursor: pointer 61 | 62 | &:checked 63 | content: "\2713" 64 | color: $green 65 | display: flex 66 | justify-content: center 67 | align-items: center 68 | font-weight: 700 69 | font-size: 18pt 70 | 71 | &::before 72 | color: $green 73 | 74 | &:checked::before 75 | content: "\2713" -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/NoIntegrationsTab/NoIntegrationsTab.jsx: -------------------------------------------------------------------------------- 1 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 2 | import {Trans} from "react-i18next"; 3 | import React from "react"; 4 | import "./styles.sass"; 5 | 6 | export const NoIntegrationsTab = ({onClick, integration}) => ( 7 |
8 | 9 |

}}> 11 | integrations.none_active

12 |
13 | ) -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/NoIntegrationsTab/index.js: -------------------------------------------------------------------------------- 1 | export {NoIntegrationsTab as default} from "./NoIntegrationsTab"; -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/components/NoIntegrationsTab/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .no-integrations 4 | display: flex 5 | justify-content: center 6 | align-items: center 7 | flex-direction: column 8 | gap: 1rem 9 | height: 100% 10 | text-align: center 11 | 12 | svg 13 | width: 3rem 14 | height: 3rem 15 | color: $subtext 16 | 17 | .integration-add 18 | color: $green 19 | cursor: pointer -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/index.js: -------------------------------------------------------------------------------- 1 | export * from './IntegrationDialog'; -------------------------------------------------------------------------------- /client/src/common/components/IntegrationDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .integration-dialog 4 | display: flex 5 | margin-left: 0.5rem 6 | margin-right: 0.5rem 7 | gap: 1rem 8 | width: 45rem 9 | margin-top: 1rem 10 | height: 20rem 11 | 12 | .integrations-tab 13 | display: flex 14 | flex-direction: column 15 | gap: 0.5rem 16 | overflow-y: scroll 17 | overflow-x: clip 18 | width: 70% 19 | 20 | .pr-integration-container 21 | display: flex 22 | gap: 0.5rem 23 | align-items: center 24 | justify-content: center 25 | padding: 0.5rem 26 | border-radius: 0.5rem 27 | border: 2px solid $red 28 | color: $red 29 | font-size: 1.25rem 30 | 31 | p 32 | margin: 0 33 | font-weight: 500 34 | 35 | @media (max-width: 781px) 36 | .integration-dialog 37 | width: 90vw 38 | margin-left: 0 39 | height: 100% 40 | margin-right: 0 41 | margin-top: 0.5rem 42 | flex-direction: column 43 | 44 | .integrations-tab 45 | max-height: 15rem 46 | width: 100% -------------------------------------------------------------------------------- /client/src/common/components/LanguageDialog/index.js: -------------------------------------------------------------------------------- 1 | export {LanguageDialog as default} from "./LanguageDialog"; -------------------------------------------------------------------------------- /client/src/common/components/LanguageDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .language-chooser-dialog 4 | margin-top: 1rem 5 | margin-bottom: 1rem 6 | display: grid 7 | grid-template-columns: repeat(2, 1fr) 8 | grid-gap: 1rem 9 | height: 20rem 10 | overflow-y: scroll 11 | 12 | .language-chooser-item 13 | display: flex 14 | width: 10rem 15 | height: 2.5rem 16 | align-items: center 17 | justify-content: center 18 | padding: 0.5rem 19 | gap: 0.5rem 20 | cursor: pointer 21 | transition: background-color 0.3s 22 | border-radius: 0.5rem 23 | border: 1px solid $light-gray 24 | 25 | &:hover 26 | background-color: $darker-gray 27 | 28 | img 29 | width: 2rem 30 | margin-right: 0.5rem 31 | border-radius: 0.2rem 32 | 33 | p 34 | text-align: center 35 | font-size: 1.2rem 36 | color: $subtext 37 | 38 | .language-selected 39 | background-color: $light-gray 40 | color: $white 41 | 42 | &:hover 43 | background-color: $light-gray 44 | 45 | @media screen and (max-height: 425px) 46 | .language-chooser-dialog 47 | height: 15rem 48 | 49 | @media screen and (max-height: 375px) 50 | .language-chooser-dialog 51 | height: 5rem 52 | 53 | @media screen and (max-width: 425px) 54 | .language-chooser-dialog 55 | grid-template-columns: 1fr 56 | 57 | .language-chooser-item 58 | margin-left: 1.5rem 59 | margin-right: 1.5rem -------------------------------------------------------------------------------- /client/src/common/components/LoadingDialog/LoadingDialog.jsx: -------------------------------------------------------------------------------- 1 | import {DialogProvider} from "@/common/contexts/Dialog"; 2 | import "./styles.sass"; 3 | 4 | export const LoadingDialog = (props) => { 5 | 6 | return ( 7 | <> 8 | {props.isOpen && 9 |
10 |
11 |
12 | } 13 | 14 | ) 15 | } -------------------------------------------------------------------------------- /client/src/common/components/LoadingDialog/index.js: -------------------------------------------------------------------------------- 1 | export * from './LoadingDialog'; -------------------------------------------------------------------------------- /client/src/common/components/LoadingDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .dialog-loading 4 | display: flex 5 | justify-content: center 6 | align-content: center 7 | align-items: center 8 | width: 70px 9 | height: 70px -------------------------------------------------------------------------------- /client/src/common/components/ProviderDialog/assets/img/cloudflare.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/components/ProviderDialog/assets/img/cloudflare.webp -------------------------------------------------------------------------------- /client/src/common/components/ProviderDialog/assets/img/libre.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/components/ProviderDialog/assets/img/libre.webp -------------------------------------------------------------------------------- /client/src/common/components/ProviderDialog/assets/img/ookla.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/components/ProviderDialog/assets/img/ookla.webp -------------------------------------------------------------------------------- /client/src/common/components/ProviderDialog/index.js: -------------------------------------------------------------------------------- 1 | export {ProviderDialog as default} from "./ProviderDialog"; -------------------------------------------------------------------------------- /client/src/common/components/ProviderDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .provider-dialog-content 4 | display: flex 5 | margin: 1rem 0.5rem 6 | user-select: none 7 | flex-direction: column 8 | 9 | .provider-header 10 | display: flex 11 | gap: 1rem 12 | 13 | .provider-item 14 | display: flex 15 | align-items: center 16 | padding: 0.3rem 0.5rem 17 | gap: 0.5rem 18 | border-radius: 0.8rem 19 | border: 2px solid $light-gray 20 | color: $subtext 21 | cursor: pointer 22 | 23 | img 24 | width: 2.5rem 25 | height: 2.5rem 26 | 27 | h3 28 | margin: 0 29 | 30 | &:hover 31 | background-color: $darker-gray 32 | 33 | .provider-item-active 34 | background-color: $light-gray 35 | 36 | &:hover 37 | background-color: $light-gray 38 | 39 | .provider-content 40 | display: flex 41 | flex-direction: column 42 | margin-top: 1rem 43 | 44 | 45 | .provider-setting 46 | display: flex 47 | gap: 1rem 48 | align-items: center 49 | justify-content: space-between 50 | 51 | .provider-input 52 | width: 20rem 53 | box-sizing: border-box 54 | margin-top: 0.5rem 55 | margin-bottom: 0.5rem 56 | font-size: 1.3rem 57 | 58 | h3 59 | color: $subtext 60 | 61 | .cloudflare-provider-info 62 | color: $subtext 63 | text-align: center 64 | 65 | .provider-dialog-footer 66 | display: flex 67 | align-items: center 68 | justify-content: space-between 69 | 70 | .provider-license-box 71 | display: flex 72 | align-items: center 73 | gap: 0.5rem 74 | 75 | input 76 | border: 2px solid $light-gray 77 | 78 | .cb-error 79 | border-color: $red 80 | 81 | label 82 | color: $subtext 83 | max-width: 16rem 84 | flex: 1 85 | 86 | 87 | @media screen and (max-width: 610px) 88 | .provider-dialog-content 89 | .provider-header 90 | flex-direction: column 91 | 92 | @media screen and (max-width: 520px) 93 | .provider-dialog-content 94 | .provider-setting .provider-input 95 | width: 60% -------------------------------------------------------------------------------- /client/src/common/components/StorageDialog/index.js: -------------------------------------------------------------------------------- 1 | export {StorageDialog as default} from "./StorageDialog"; -------------------------------------------------------------------------------- /client/src/common/components/StorageDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .storage-dialog 4 | display: flex 5 | margin-left: 0.5rem 6 | margin-right: 0.5rem 7 | gap: 1rem 8 | width: 45rem 9 | margin-top: 1rem 10 | height: 14rem 11 | user-select: none 12 | 13 | .storage-options 14 | display: flex 15 | flex-direction: column 16 | justify-content: space-between 17 | 18 | .storage-top 19 | display: flex 20 | flex-direction: column 21 | gap: 0.5rem 22 | user-select: none 23 | overflow-x: hidden 24 | overflow-y: scroll 25 | 26 | .storage-tab 27 | display: flex 28 | gap: 0.5rem 29 | align-items: center 30 | padding: 0.6rem 0.7rem 31 | color: $subtext 32 | border: 2px solid transparent 33 | border-radius: 1rem 34 | cursor: pointer 35 | 36 | svg 37 | width: 1.3rem 38 | height: 1.3rem 39 | p 40 | margin: 0 41 | font-size: 14pt 42 | 43 | 44 | .reset-cursor 45 | cursor: default 46 | 47 | .storage-item-active 48 | background-color: $light-gray 49 | color: $white 50 | 51 | .storage-manager 52 | width: 70% 53 | display: flex 54 | flex-direction: column 55 | gap: 1rem 56 | 57 | .storage-manager .storage-row 58 | display: flex 59 | justify-content: space-between 60 | align-items: center 61 | 62 | h3 63 | margin: 0 64 | color: $subtext 65 | 66 | .dialog-btn 67 | padding: 0.4rem 1rem 68 | 69 | 70 | @media (max-width: 781px) 71 | .storage-dialog 72 | width: 90vw 73 | margin-left: 0 74 | height: 100% 75 | margin-top: 0.5rem 76 | margin-right: 0 77 | flex-direction: column 78 | 79 | .storage-bottom 80 | display: none 81 | 82 | .storage-manager 83 | max-height: 15rem 84 | width: 100% 85 | 86 | .storage-top 87 | flex-direction: row 88 | justify-content: center 89 | overflow-x: scroll 90 | overflow-y: hidden 91 | gap: 0.5rem -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/banner.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/client/src/common/components/WelcomeDialog/banner.webp -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/index.js: -------------------------------------------------------------------------------- 1 | export {WelcomeDialog as default} from "./WelcomeDialog"; -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/steps/DataHelper/index.js: -------------------------------------------------------------------------------- 1 | export {DataHelper as default} from "./DataHelper"; -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/steps/DataHelper/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .data-helper 4 | 5 | h2 6 | margin: 0 0 0.5rem 7 | color: $subtext 8 | 9 | p 10 | margin: 0 11 | color: $subtext 12 | 13 | 14 | .speeds 15 | display: flex 16 | justify-content: center 17 | gap: 2rem 18 | margin-top: 1rem 19 | 20 | .speed 21 | display: flex 22 | flex-direction: column 23 | align-items: center 24 | 25 | input 26 | box-sizing: border-box 27 | width: 100% 28 | 29 | .speed-header 30 | display: flex 31 | align-items: center 32 | margin-bottom: 0.5rem 33 | 34 | svg 35 | font-size: 28pt 36 | margin-right: 0.5rem 37 | color: $green 38 | 39 | .speed-text h2 40 | margin: 0 41 | color: $subtext 42 | 43 | .speed-text p 44 | margin: 0 45 | color: $subtext 46 | 47 | 48 | @media screen and (max-width: 600px) 49 | .data-helper .speeds 50 | flex-direction: column 51 | gap: 1rem 52 | margin-top: 0.5rem 53 | 54 | .speed 55 | width: 100% 56 | flex-direction: row 57 | justify-content: space-between 58 | 59 | input 60 | width: 50% -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/steps/Greetings/Greetings.jsx: -------------------------------------------------------------------------------- 1 | import Banner from "@/common/components/WelcomeDialog/banner.webp"; 2 | import "./styles.sass"; 3 | import {t} from "i18next"; 4 | 5 | export const Greetings = () => { 6 | return ( 7 |
8 | Welcome banner 9 |

{t("welcome.title")}

10 |

{t("welcome.subtext")}

11 |
12 | ); 13 | } -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/steps/Greetings/index.js: -------------------------------------------------------------------------------- 1 | export {Greetings as default} from "./Greetings"; -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/steps/Greetings/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .welcome-greetings 4 | display: flex 5 | margin: 1rem 0 6 | flex-direction: column 7 | align-items: center 8 | height: 100% 9 | justify-content: center 10 | text-align: center 11 | gap: 1rem 12 | 13 | img 14 | height: 5rem 15 | 16 | h2 17 | margin: 0 18 | font-size: 24pt 19 | color: $white 20 | 21 | p 22 | margin: 0 23 | padding-left: 2rem 24 | padding-right: 2rem 25 | font-size: 14pt 26 | color: $subtext 27 | -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/steps/OoklaLicense/OoklaLicense.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 3 | import {faFileLines} from "@fortawesome/free-solid-svg-icons"; 4 | import {t} from "i18next"; 5 | 6 | export const documents = [ 7 | {url: "https://www.speedtest.net/about/terms", title: "Ookla ToS"}, 8 | {url: "https://www.speedtest.net/about/eula", title: "Ookla EULA"}, 9 | {url: "https://www.speedtest.net/about/privacy", title: "Ookla GDPR"} 10 | ] 11 | 12 | export const OoklaLicense = () => { 13 | return ( 14 |
15 |

{t("welcome.accept_title")}

16 |

17 | {t("welcome.accept_subtext")} 18 |

19 |
28 |
29 | ); 30 | } -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/steps/OoklaLicense/index.js: -------------------------------------------------------------------------------- 1 | export {OoklaLicense as default} from "./OoklaLicense"; -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/steps/OoklaLicense/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .ookla-license 4 | h2 5 | margin: 0 0 0.5rem 6 | color: $subtext 7 | 8 | p 9 | margin: 0 10 | color: $subtext 11 | 12 | .documents 13 | display: flex 14 | flex-direction: column 15 | margin-top: 1rem 16 | 17 | .document 18 | display: flex 19 | align-items: center 20 | margin: 0.3rem 0 21 | color: $green 22 | text-decoration: none 23 | 24 | svg 25 | margin-right: 0.5rem 26 | font-size: 1.5rem 27 | 28 | p 29 | color: $green -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/steps/ProviderChooser/ProviderChooser.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | 3 | import {providers} from "@/common/components/ProviderDialog/ProviderDialog"; 4 | import {t} from "i18next"; 5 | 6 | export const ProviderChooser = ({provider, setProvider}) => { 7 | return ( 8 |
9 |

{t("welcome.provider_title")}

10 |

{t("welcome.provider_subtext")}

11 |
12 | {providers.map((current) => ( 13 |
setProvider(current.id)} key={current.id}> 15 | {current.name}/ 16 |

{current.name}

17 |
18 | ))} 19 |
20 |
21 | ); 22 | } -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/steps/ProviderChooser/index.js: -------------------------------------------------------------------------------- 1 | export {ProviderChooser as default} from "./ProviderChooser"; -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/steps/ProviderChooser/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .provider-chooser 4 | 5 | h2 6 | margin: 0 0 0.5rem 7 | color: $subtext 8 | 9 | p 10 | margin: 0 11 | color: $subtext 12 | 13 | .provider-list 14 | margin-top: 1rem 15 | display: flex 16 | gap: 1rem 17 | flex-wrap: wrap 18 | 19 | .provider-item 20 | display: flex 21 | align-items: center 22 | padding: 0.2rem 1rem 23 | gap: 0.5rem 24 | border-radius: 0.8rem 25 | border: 2px solid $light-gray 26 | color: $subtext 27 | cursor: pointer 28 | 29 | img 30 | width: 3rem 31 | height: 3rem 32 | 33 | h2 34 | margin: 0 35 | 36 | &:hover 37 | background-color: $darker-gray 38 | 39 | .provider-item-active 40 | background-color: $light-gray 41 | 42 | &:hover 43 | background-color: $light-gray -------------------------------------------------------------------------------- /client/src/common/components/WelcomeDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .welcome-banner 4 | width: 30rem 5 | display: flex 6 | flex-direction: column 7 | justify-content: space-between 8 | user-select: none 9 | 10 | .welcome-inner 11 | height: 100% 12 | 13 | .welcome-actions 14 | display: flex 15 | justify-content: space-between 16 | align-items: center 17 | margin-top: 1rem 18 | 19 | h3 20 | margin: 0 21 | font-size: 14pt 22 | color: $subtext 23 | 24 | .dialog-btn 25 | padding: 0.4rem 1.3rem 26 | border-radius: 0.6rem 27 | 28 | .slide-in 29 | animation: slide-in 0.5s forwards 30 | 31 | @keyframes slide-in 32 | from 33 | opacity: 0 34 | transform: translateX(10%) rotate(10deg) scale(0.5) 35 | to 36 | opacity: 1 37 | transform: translateX(0) 38 | 39 | 40 | @media screen and (max-width: 600px) 41 | .welcome-banner 42 | width: 100% 43 | 44 | .slide-in 45 | animation: slide-in 0.5s forwards -------------------------------------------------------------------------------- /client/src/common/contexts/Config/dialog.jsx: -------------------------------------------------------------------------------- 1 | import {t} from "i18next"; 2 | 3 | export const passwordRequiredDialog = () => ({ 4 | title: t("dialog.password.title"), 5 | placeholder: t("dialog.password.placeholder"), 6 | description: localStorage.getItem("password") ? {t("dialog.password.wrong")} : "", 7 | type: "password", 8 | buttonText: t("dialog.login"), 9 | disableCloseButton: true, 10 | onSuccess: (value) => { 11 | localStorage.setItem("password", value); 12 | window.location.reload(); 13 | } 14 | }); 15 | 16 | export const apiErrorDialog = () => ({ 17 | title: t("dialog.api.title"), 18 | description: {t("dialog.api.description")}, 19 | buttonText: t("dialog.retry"), 20 | disableCloseButton: true, 21 | onSuccess: () => window.location.reload() 22 | }); -------------------------------------------------------------------------------- /client/src/common/contexts/Config/index.js: -------------------------------------------------------------------------------- 1 | export * from './ConfigContext'; -------------------------------------------------------------------------------- /client/src/common/contexts/Dialog/DialogContext.jsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useEffect, useRef} from "react"; 2 | import "./styles.sass"; 3 | 4 | export const DialogContext = createContext({}); 5 | 6 | export const DialogProvider = (props) => { 7 | const areaRef = useRef(); 8 | const ref = useRef(); 9 | 10 | const close = (force = false) => { 11 | if (props.disableClosing && !force) return; 12 | areaRef.current?.classList.add("dialog-area-hidden"); 13 | ref.current?.classList.add("dialog-hidden"); 14 | } 15 | 16 | const onClose = (e) => { 17 | if (e.animationName === "fadeOut") { 18 | hideTooltips(false); 19 | props?.close(); 20 | } 21 | } 22 | 23 | const handleKeyDown = (e) => { 24 | if (e.code === "Enter" && props.submit) props.submit(); 25 | } 26 | 27 | const hideTooltips = (state) => Array.from(document.getElementsByClassName("tooltip")).forEach(element => { 28 | if (state && !element.classList.contains("tooltip-invisible")) 29 | element.classList.add("tooltip-invisible"); 30 | if (!state && element.classList.contains("tooltip-invisible")) 31 | element.classList.remove("tooltip-invisible"); 32 | }); 33 | 34 | useEffect(() => { 35 | const handleClick = (event) => { 36 | if (!ref.current?.contains(event.target)) close(); 37 | } 38 | 39 | document.addEventListener("mousedown", handleClick); 40 | 41 | return () => document.removeEventListener("mousedown", handleClick); 42 | }, [ref]); 43 | 44 | return ( 45 | 46 |
47 |
hideTooltips(true)}> 49 | {props.children} 50 |
51 |
52 |
53 | ) 54 | } -------------------------------------------------------------------------------- /client/src/common/contexts/Dialog/index.js: -------------------------------------------------------------------------------- 1 | export * from './DialogContext'; -------------------------------------------------------------------------------- /client/src/common/contexts/Dialog/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .dialog-area 4 | position: fixed 5 | top: 0 6 | bottom: 0 7 | left: 0 8 | right: 0 9 | width: 100% 10 | height: 100% 11 | background-color: rgba(0, 0, 0, 0.6) 12 | display: flex 13 | align-items: center 14 | z-index: 10 15 | justify-content: center 16 | backdrop-filter: blur(2px) 17 | transition: all 0.2s 18 | animation: opacity 0.3s 19 | 20 | .dialog-area-hidden 21 | opacity: 0 22 | animation: opacity 0.3s reverse 23 | 24 | .dialog 25 | padding: 15px 26 | background-color: $dark-gray 27 | border: 1px solid $light-gray 28 | border-radius: 15px 29 | transition: all 0.2s 30 | animation: fadeIn 0.3s 31 | 32 | .dialog-hidden 33 | visibility: hidden 34 | opacity: 0 35 | animation: fadeOut 0.3s 36 | 37 | .dialog-header 38 | display: flex 39 | align-items: center 40 | justify-content: space-between 41 | user-select: none 42 | 43 | .dialog a 44 | color: $green 45 | cursor: pointer 46 | 47 | .dialog-main 48 | display: flex 49 | justify-content: center 50 | align-items: center 51 | flex-direction: column 52 | 53 | .dialog-buttons 54 | display: flex 55 | margin-top: 5px 56 | justify-content: right 57 | 58 | .dialog-text 59 | font-size: 16pt 60 | color: $subtext 61 | margin: 0 62 | 63 | .dialog-description 64 | font-size: 16pt 65 | margin: 10px 2px 2px 66 | color: $subtext 67 | 68 | .dialog-description a 69 | color: $green 70 | text-decoration: underline 71 | 72 | .dialog-value 73 | color: $green 74 | 75 | .dialog-icon 76 | cursor: pointer 77 | 78 | .dialog-icon:hover 79 | color: $red 80 | 81 | .dialog-btn 82 | font-size: 16pt 83 | padding: 8px 15px 84 | border: none 85 | font-weight: 700 86 | border-radius: 5px 87 | color: $dark-gray 88 | background-color: $green 89 | cursor: pointer 90 | margin-left: 5px 91 | margin-right: 5px 92 | transition: all 0.2s 93 | 94 | .dialog-btn:hover 95 | filter: brightness(0.8) 96 | 97 | .dialog-secondary 98 | background-color: $red 99 | 100 | .dialog-secondary:hover 101 | background-color: $red -------------------------------------------------------------------------------- /client/src/common/contexts/InputDialog/index.js: -------------------------------------------------------------------------------- 1 | export * from './InputDialog'; -------------------------------------------------------------------------------- /client/src/common/contexts/InputDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .input-dialog 4 | width: 480px 5 | 6 | .dialog-input 7 | font-size: 18pt 8 | padding: 15px 9 | font-weight: 700 10 | margin-top: 15px 11 | margin-bottom: 15px 12 | background-color: $darker-gray 13 | color: $subtext 14 | border: 1px solid $light-gray 15 | border-radius: 15px 16 | text-align: center 17 | box-sizing: border-box 18 | outline: none 19 | 20 | .dialog-input:focus 21 | border: 1px solid $green 22 | 23 | .input-error 24 | border: 1px solid $red -------------------------------------------------------------------------------- /client/src/common/contexts/Node/NodeContext.jsx: -------------------------------------------------------------------------------- 1 | import React, {useState, createContext, useEffect, useContext} from "react"; 2 | import {baseRequest} from "@/common/utils/RequestUtil"; 3 | import {ConfigContext} from "@/common/contexts/Config"; 4 | 5 | export const NodeContext = createContext({}); 6 | 7 | export const NodeProvider = (props) => { 8 | 9 | const [config] = useContext(ConfigContext); 10 | const [nodes, setNodes] = useState([]); 11 | const [currentNode, setCurrentNode] = useState(parseInt(localStorage.getItem("currentNode")) || 0); 12 | 13 | const updateNodes = async () => baseRequest("/nodes").then(async nodes => { 14 | if (nodes.ok) setNodes(await nodes.json()); 15 | }); 16 | 17 | useEffect(() => { 18 | if (Object.keys(config).length === 0) return; 19 | if (!config.viewMode) updateNodes(); 20 | }, [config]); 21 | 22 | const updateCurrentNode = (node) => { 23 | localStorage.setItem("currentNode", node); 24 | setCurrentNode(parseInt(node)); 25 | } 26 | 27 | const findNode = (nodeId) => nodes?.find(node => node.id === nodeId); 28 | 29 | return ( 30 | 31 | {props.children} 32 | 33 | ) 34 | } -------------------------------------------------------------------------------- /client/src/common/contexts/Node/index.js: -------------------------------------------------------------------------------- 1 | export * from "./NodeContext"; -------------------------------------------------------------------------------- /client/src/common/contexts/Speedtests/SpeedtestContext.jsx: -------------------------------------------------------------------------------- 1 | import React, {useState, createContext, useEffect} from "react"; 2 | import {jsonRequest} from "@/common/utils/RequestUtil"; 3 | 4 | export const SpeedtestContext = createContext({}); 5 | 6 | export const SpeedtestProvider = (props) => { 7 | 8 | const [speedtests, setSpeedtests] = useState({}); 9 | 10 | const generatePath = (level = 1) => { 11 | switch (level) { 12 | case 1: 13 | return "?hours=24"; 14 | case 2: 15 | return "?hours=48"; 16 | case 3: 17 | return "/averages?days=7"; 18 | case 4: 19 | return "/averages?days=30"; 20 | } 21 | } 22 | 23 | const updateTests = () => jsonRequest("/speedtests" + generatePath(parseInt(localStorage.getItem("testTime") || 1))) 24 | .then(tests => setSpeedtests(tests)); 25 | 26 | 27 | useEffect(() => { 28 | updateTests(); 29 | const interval = setInterval(() => updateTests(), 15000); 30 | return () => clearInterval(interval); 31 | }, []); 32 | 33 | return ( 34 | 35 | {props.children} 36 | 37 | ) 38 | } -------------------------------------------------------------------------------- /client/src/common/contexts/Speedtests/index.js: -------------------------------------------------------------------------------- 1 | export * from './SpeedtestContext'; -------------------------------------------------------------------------------- /client/src/common/contexts/Status/StatusContext.jsx: -------------------------------------------------------------------------------- 1 | import React, {useState, createContext, useEffect} from "react"; 2 | import {jsonRequest} from "@/common/utils/RequestUtil"; 3 | 4 | export const StatusContext = createContext({}); 5 | 6 | export const StatusProvider = (props) => { 7 | 8 | const [status, setStatus] = useState({paused: false, running: false}); 9 | 10 | const updateStatus = () => jsonRequest("/speedtests/status").then(status => setStatus(status)); 11 | 12 | useEffect(() => { 13 | updateStatus(); 14 | const interval = setInterval(() => updateStatus(), 5000); 15 | return () => clearInterval(interval); 16 | }, []); 17 | 18 | return ( 19 | 20 | {props.children} 21 | 22 | ) 23 | } -------------------------------------------------------------------------------- /client/src/common/contexts/Status/index.js: -------------------------------------------------------------------------------- 1 | export * from './StatusContext'; -------------------------------------------------------------------------------- /client/src/common/contexts/ToastNotification/ToastNotificationContext.jsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useRef, useState} from "react"; 2 | import {faExclamationTriangle} from "@fortawesome/free-solid-svg-icons"; 3 | import "./styles.sass"; 4 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 5 | 6 | export const ToastNotificationContext = createContext({}); 7 | 8 | export const ToastNotificationProvider = (props) => { 9 | 10 | const notificationRef = useRef(); 11 | const [timeOutId, setTimeOutId] = useState(null); 12 | const [toastNotification, setToastNotification] = useState(null); 13 | 14 | const updateToast = (text, color = "red", icon = faExclamationTriangle) => { 15 | setToastNotification({text, color, icon}); 16 | 17 | if (timeOutId) { 18 | clearTimeout(timeOutId); 19 | setTimeOutId(null); 20 | } 21 | setTimeOutId(setTimeout(close, 5000)); 22 | } 23 | 24 | const close = () => { 25 | notificationRef.current.classList.add("toast-hidden"); 26 | } 27 | 28 | const onAnimationEnd = (event) => { 29 | if (event.animationName === "moveOut") 30 | setToastNotification(null); 31 | } 32 | 33 | return ( 34 | 35 |
37 | {toastNotification &&
38 | 39 |

{toastNotification.text}

40 |
} 41 |
42 | 43 | {props.children} 44 |
45 | ); 46 | } -------------------------------------------------------------------------------- /client/src/common/contexts/ToastNotification/index.js: -------------------------------------------------------------------------------- 1 | export * from "./ToastNotificationContext"; -------------------------------------------------------------------------------- /client/src/common/contexts/ToastNotification/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .toast-notification 4 | position: fixed 5 | bottom: 2rem 6 | right: 1rem 7 | z-index: 5 8 | background-color: $darker-gray 9 | box-shadow: 0 0 1rem $darker-gray 10 | border: 2px solid $light-gray 11 | border-radius: 0.5rem 12 | animation: 0.5s moveIn 13 | cursor: pointer 14 | 15 | .toast-hidden 16 | visibility: hidden 17 | transition: all 0s 0.5s 18 | animation: 0.5s moveOut 19 | 20 | .toast-green 21 | transition: all 0.3s 22 | 23 | & .toast-content svg 24 | color: $green 25 | 26 | .toast-green:hover 27 | border-color: $green 28 | filter: brightness(0.9) 29 | 30 | .toast-red 31 | & .toast-content svg 32 | color: $red 33 | 34 | .toast-red:hover 35 | border-color: $red 36 | filter: brightness(0.9) 37 | 38 | .toast-content 39 | display: flex 40 | align-items: center 41 | padding: 1rem 1rem 42 | color: $white 43 | font-size: 14px 44 | font-weight: 500 45 | 46 | .toast-content svg 47 | margin-right: 1rem 48 | width: 2rem 49 | height: 2rem 50 | 51 | .toast-content h2 52 | margin: 0 53 | font-size: 1.4rem 54 | 55 | 56 | @keyframes moveIn 57 | 0% 58 | transform: translateX(100%) 59 | 60% 60 | transform: translateX(-10%) 61 | 100% 62 | transform: translateX(0) 63 | 64 | @keyframes moveOut 65 | 0% 66 | transform: translateX(0) 67 | 60% 68 | transform: translateX(-10%) 69 | 100% 70 | transform: translateX(100%) 71 | 72 | 73 | @media screen and (max-width: 425px) 74 | .toast-notification 75 | bottom: 1rem 76 | right: 1rem 77 | left: 1rem 78 | 79 | @keyframes moveIn 80 | 0% 81 | transform: translateY(100%) 82 | 60% 83 | transform: translateY(-10%) 84 | 100% 85 | transform: translateY(0) 86 | 87 | @keyframes moveOut 88 | 0% 89 | transform: translateY(0) 90 | 60% 91 | transform: translateY(-10%) 92 | 100% 93 | transform: translateY(100%) -------------------------------------------------------------------------------- /client/src/common/styles/_colors.sass: -------------------------------------------------------------------------------- 1 | $background: #232835 2 | 3 | $light-gray: #353A47 4 | $dark-gray: #1d2128 5 | $darker-gray: #20252F 6 | 7 | $white: #F1F1F1 8 | $subtext: #C8C8C8 9 | 10 | $blue: #456AC6 11 | $orange: #E58A00 12 | $green: #45C65A 13 | $red: #C64545 -------------------------------------------------------------------------------- /client/src/common/styles/default.sass: -------------------------------------------------------------------------------- 1 | @import "colors" 2 | 3 | body, html 4 | margin: 0 5 | overflow-x: hidden 6 | background-color: $background 7 | font-family: "Inter", sans-serif 8 | font-weight: 700 9 | 10 | ::-webkit-scrollbar 11 | width: 13px 12 | 13 | ::-webkit-scrollbar-thumb 14 | background: $darker-gray 15 | border-radius: 10px 16 | 17 | ::-webkit-scrollbar-thumb:hover 18 | background: $dark-gray 19 | 20 | .speedtest-icon 21 | font-size: 26pt 22 | color: $white 23 | margin-right: 10px 24 | 25 | .container-icon 26 | width: 35px 27 | height: 35px 28 | color: $white 29 | font-size: 26pt 30 | 31 | .help-icon 32 | cursor: help 33 | 34 | .icon-red 35 | color: $red 36 | 37 | .icon-error 38 | color: $red 39 | filter: brightness(0.8) 40 | 41 | .icon-green 42 | color: $green 43 | 44 | .icon-orange 45 | color: $orange 46 | 47 | .icon-blue 48 | color: $blue 49 | 50 | main 51 | display: flex 52 | flex-direction: column 53 | align-items: center 54 | margin-left: 1rem 55 | margin-right: 1rem 56 | 57 | @keyframes opacity 58 | 0% 59 | opacity: 0 60 | 100% 61 | opacity: 1 62 | 63 | @keyframes fadeIn 64 | 0% 65 | opacity: 0 66 | transform: scale(0.4) 67 | filter: blur(5px) 68 | 100% 69 | opacity: 1 70 | 71 | @keyframes fadeOut 72 | 0% 73 | opacity: 1 74 | 100% 75 | opacity: 0 76 | transform: scale(0.4) 77 | filter: blur(5px) 78 | 79 | @media (max-width: 730px) 80 | ::-webkit-scrollbar 81 | width: 5px -------------------------------------------------------------------------------- /client/src/common/styles/spinner.sass: -------------------------------------------------------------------------------- 1 | @import "colors" 2 | 3 | .lds-ellipsis 4 | display: inline-block 5 | position: relative 6 | width: 80px 7 | height: 80px 8 | 9 | .lds-ellipsis div 10 | position: absolute 11 | top: 33px 12 | width: 13px 13 | height: 13px 14 | border-radius: 50% 15 | background: $white 16 | animation-timing-function: cubic-bezier(0, 1, 1, 0) 17 | 18 | .lds-ellipsis div:nth-child(1) 19 | left: 8px 20 | animation: lds-ellipsis1 0.6s infinite 21 | 22 | .lds-ellipsis div:nth-child(2) 23 | left: 8px 24 | animation: lds-ellipsis2 0.6s infinite 25 | 26 | .lds-ellipsis div:nth-child(3) 27 | left: 32px 28 | animation: lds-ellipsis2 0.6s infinite 29 | 30 | .lds-ellipsis div:nth-child(4) 31 | left: 56px 32 | animation: lds-ellipsis3 0.6s infinite 33 | 34 | @keyframes lds-ellipsis1 35 | 0% 36 | transform: scale(0) 37 | 100% 38 | transform: scale(1) 39 | 40 | @keyframes lds-ellipsis3 41 | 0% 42 | transform: scale(1) 43 | 100% 44 | transform: scale(0) 45 | 46 | 47 | @keyframes lds-ellipsis2 48 | 0% 49 | transform: translate(0, 0) 50 | 100% 51 | transform: translate(24px, 0) -------------------------------------------------------------------------------- /client/src/common/utils/TestUtil.js: -------------------------------------------------------------------------------- 1 | export function getIconBySpeed(current, optional, higherIsBetter) { 2 | let speed = Math.floor((current / optional) * 100); 3 | 4 | if (current === -1) return "error"; 5 | 6 | if (higherIsBetter) { 7 | if (speed >= 75) return "green"; 8 | if (speed >= 30) return "orange"; 9 | return "red"; 10 | } else { 11 | if (speed >= 180) return "red"; 12 | if (speed >= 130) return "orange"; 13 | return "green"; 14 | } 15 | } -------------------------------------------------------------------------------- /client/src/i18n.js: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import {initReactI18next} from "react-i18next"; 3 | import LanguageDetector from 'i18next-browser-languagedetector'; 4 | import HttpApi from 'i18next-http-backend'; 5 | import EnglishFlag from "@/common/assets/languages/en.webp"; 6 | import GermanFlag from "@/common/assets/languages/de.webp"; 7 | import BulgarianFlag from "@/common/assets/languages/bg.webp"; 8 | import ChineseFlag from "@/common/assets/languages/zh.webp"; 9 | import DutchFlag from "@/common/assets/languages/nl.webp"; 10 | import FranceFlag from "@/common/assets/languages/fr.webp"; 11 | import ItalianFlag from "@/common/assets/languages/it.webp"; 12 | import PortugueseBrazilFlag from "@/common/assets/languages/br.webp"; 13 | import RussianFlag from "@/common/assets/languages/ru.webp"; 14 | import SpanishFlag from "@/common/assets/languages/es.webp"; 15 | import TurkishFlag from "@/common/assets/languages/tr.webp"; 16 | 17 | if (localStorage.getItem('language') === null) 18 | localStorage.setItem('language', navigator.language.split('-')[0]); 19 | 20 | export const languages = [ 21 | {name: 'English', code: 'en', flag: EnglishFlag}, 22 | {name: 'Deutsch', code: 'de', flag: GermanFlag}, 23 | {name: 'Български', code: 'bg', flag: BulgarianFlag}, 24 | {name: '中文', code: 'zh', flag: ChineseFlag}, 25 | {name: 'Nederlands', code: 'nl', flag: DutchFlag}, 26 | {name: 'Français', code: 'fr', flag: FranceFlag}, 27 | {name: 'Italiano', code: 'it', flag: ItalianFlag}, 28 | {name: 'Português do Brasil', code: 'pt', flag: PortugueseBrazilFlag}, 29 | {name: 'Русский', code: 'ru', flag: RussianFlag}, 30 | {name: 'Español', code: 'es', flag: SpanishFlag}, 31 | {name: 'Türkçe', code: 'tr', flag: TurkishFlag} 32 | ] 33 | 34 | i18n.use(initReactI18next).use(LanguageDetector).use(HttpApi).init({ 35 | supportedLngs: languages.map(lang => lang.code), 36 | fallbackLng: 'en', 37 | backend: { 38 | loadPath: '/assets/locales/{{lng}}.json' 39 | }, 40 | detection: { 41 | order: ['localStorage'], 42 | lookupLocalStorage: 'language' 43 | } 44 | }); 45 | 46 | export default i18n; -------------------------------------------------------------------------------- /client/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from "react-dom/client"; 3 | import App from './App'; 4 | 5 | export const PROJECT_URL = "https://github.com/gnmyt/myspeed"; 6 | export const WEB_URL = "https://myspeed.dev"; 7 | export const PROJECT_WIKI = "https://docs.myspeed.dev"; 8 | export const DONATION_URL = "https://ko-fi.com/gnmyt"; 9 | 10 | const root = ReactDOM.createRoot(document.getElementById('root')); 11 | 12 | root.render( 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /client/src/pages/Error/Error.jsx: -------------------------------------------------------------------------------- 1 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 2 | import {faExclamationTriangle} from "@fortawesome/free-solid-svg-icons"; 3 | import {useEffect, useState} from "react"; 4 | import "./styles.sass"; 5 | 6 | export const Error = (props) => { 7 | const [reloadTimer, setReloadTimer] = useState(5); 8 | 9 | useEffect(() => { 10 | if (props.disableReload) return; 11 | const interval = setInterval(() => { 12 | if (reloadTimer > 0) { 13 | setReloadTimer(reloadTimer - 1) 14 | } else { 15 | window.location = window.location.href; 16 | } 17 | }, 1000); 18 | 19 | return () => clearInterval(interval); 20 | }, [reloadTimer]); 21 | 22 | return ( 23 |
24 | 25 |

{props.text}

26 | {!props.disableReload &&

Reloading {reloadTimer !== 0 ? <>in {reloadTimer} seconds : 27 | now}...

} 28 |
29 | ) 30 | } -------------------------------------------------------------------------------- /client/src/pages/Error/index.js: -------------------------------------------------------------------------------- 1 | export {Error as default} from "./Error"; -------------------------------------------------------------------------------- /client/src/pages/Error/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .error-page 4 | min-height: 100vh 5 | display: flex 6 | flex-direction: column 7 | justify-content: center 8 | align-items: center 9 | color: $white 10 | text-align: center 11 | margin: 0 12 | 13 | .no-reload 14 | width: unset 15 | height: unset 16 | position: center 17 | display: unset 18 | 19 | .error-page svg 20 | color: $red 21 | 22 | .error-page h1 23 | padding-left: 1rem 24 | padding-right: 1rem 25 | 26 | .error-page h2 27 | margin: 0 28 | padding-left: 0.5rem 29 | padding-right: 0.5rem 30 | 31 | .error-page span 32 | color: $green -------------------------------------------------------------------------------- /client/src/pages/Home/Home.jsx: -------------------------------------------------------------------------------- 1 | import LatestTestComponent from "./components/LatestTest"; 2 | import TestAreaComponent from "./components/TestArea"; 3 | 4 | const Home = () => ( 5 |
6 | 7 | 8 |
9 | 10 | 11 |
12 | ) 13 | 14 | 15 | export default Home; -------------------------------------------------------------------------------- /client/src/pages/Home/components/LatestTest/index.js: -------------------------------------------------------------------------------- 1 | export {default} from './LatestTestComponent'; -------------------------------------------------------------------------------- /client/src/pages/Home/components/LatestTest/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .analyse-area 4 | margin-top: 2rem 5 | display: flex 6 | padding: 2.5rem 2rem 7 | border: transparent 1px solid 8 | background-color: $dark-gray 9 | border-radius: 15px 10 | width: 100% 11 | transition: all 0.5s 12 | align-items: center 13 | justify-content: center 14 | margin-bottom: 2rem 15 | box-sizing: border-box 16 | user-select: none 17 | 18 | .pulse 19 | border: $green 2px solid 20 | animation: pulse 2s infinite 21 | 22 | @keyframes pulse 23 | 0% 24 | box-shadow: 0 0 0 0 #14AB37FF 25 | 70% 26 | box-shadow: 0 0 0 10px #CCA92C00 27 | 100% 28 | box-shadow: 0 0 0 0 #CCA92C00 29 | 30 | .inner-container 31 | margin-left: 2rem 32 | margin-right: 2rem 33 | transition: all 0.5s 34 | animation: fadeIn 0.5s 35 | 36 | .container-header 37 | display: flex 38 | align-items: center 39 | 40 | .tests-paused 41 | border: $orange 2px solid 42 | 43 | .container-text 44 | margin: 0 0 0 25px 45 | color: $white 46 | font-weight: 700 47 | font-size: 24pt 48 | white-space: nowrap 49 | 50 | .container-subtext 51 | color: $subtext 52 | font-size: 16pt 53 | margin-left: 10px 54 | font-weight: 500 55 | 56 | .container-main 57 | text-align: center 58 | color: $subtext 59 | 60 | h2 61 | font-size: 28pt 62 | font-weight: 700 63 | margin: 1rem 64 | 65 | @media (max-width: 1351px) 66 | .inner-container 67 | margin-left: 1rem 68 | margin-right: 1rem 69 | 70 | @media (max-width: 1200px) 71 | .analyse-area 72 | flex-wrap: wrap 73 | .mobile-break 74 | width: 100% 75 | .analyse-area 76 | justify-content: space-evenly 77 | 78 | @media (max-width: 730px) 79 | .analyse-area 80 | flex-direction: column 81 | 82 | @media (max-width: 475px) 83 | .analyse-area 84 | padding-left: 1rem 85 | padding-bottom: 1rem 86 | padding-top: 1rem 87 | .inner-container 88 | margin: 0 -------------------------------------------------------------------------------- /client/src/pages/Home/components/LatestTest/utils.js: -------------------------------------------------------------------------------- 1 | import {t} from "i18next"; 2 | 3 | export function generateRelativeTime(created) { 4 | let currentDate = new Date().getTime(); 5 | let date = new Date(Date.parse(created)).getTime(); 6 | 7 | const diff = (currentDate - date) / 1000; 8 | 9 | if (diff < 5) { 10 | return t("time.now"); 11 | } else if (diff < 60) { 12 | return t("time.seconds", {replace: {seconds: Math.floor(diff)}}); 13 | } else if (diff < 3600) { 14 | return Math.floor(diff / 60) === 1 ? t("time.minute") : t("time.minutes", {replace: {minutes: Math.floor(diff / 60)}}); 15 | } else if (diff < 86400) { 16 | return Math.floor(diff / 3600) === 1 ? t("time.hour") : t("time.hours", {replace: {hours: Math.floor(diff / 3600)}}); 17 | } 18 | 19 | return "N/A" 20 | } -------------------------------------------------------------------------------- /client/src/pages/Home/components/LatestTest/utils/dialogs.jsx: -------------------------------------------------------------------------------- 1 | import {t} from "i18next"; 2 | import {Trans} from "react-i18next"; 3 | 4 | export const downloadInfo = () => ({title: t("info.down.title"), description: t("info.down.description"), buttonText: t("dialog.okay")}); 5 | 6 | export const pingInfo = () => ({title: t("info.ping.title"), description: t("info.ping.description"), buttonText: t("dialog.okay")}); 7 | 8 | export const uploadInfo = () => ({title: t("info.up.title"), description: t("info.up.description"), buttonText: t("dialog.okay")}); 9 | 10 | export const latestTestInfo = (latest) => ({ 11 | title: t("info.latest.title"), 12 | description: latest.created ? }} values={{date: new Date(latest.created).toLocaleDateString(), 13 | time: new Date(latest.created).toLocaleTimeString(undefined, {hour: "2-digit", minute: "2-digit"})}}> 14 | info.latest.description : t("test.no_latest"), 15 | buttonText: t("dialog.okay") 16 | }); -------------------------------------------------------------------------------- /client/src/pages/Home/components/Speedtest/index.js: -------------------------------------------------------------------------------- 1 | export {default} from './SpeedtestComponent'; -------------------------------------------------------------------------------- /client/src/pages/Home/components/Speedtest/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .speedtest 4 | margin-bottom: 2rem 5 | display: flex 6 | padding: 1.5rem 2rem 7 | border: $light-gray 2px solid 8 | background-color: $dark-gray 9 | border-radius: 15px 10 | justify-content: space-between 11 | width: 100% 12 | flex-wrap: wrap 13 | transition: all 0.5s 14 | animation: fadeIn 0.5s 15 | cursor: pointer 16 | user-select: none 17 | box-sizing: border-box 18 | 19 | .speedtest:hover 20 | border: $green 2px solid 21 | 22 | .speedtest-hidden 23 | visibility: hidden 24 | opacity: 0 25 | animation: fadeOut 0.3s 26 | 27 | .speedtest-row 28 | display: flex 29 | align-items: center 30 | 31 | .date 32 | display: flex 33 | align-items: center 34 | 35 | .date-text 36 | margin: 0 0 0 1rem 37 | font-size: 24pt 38 | font-weight: 700 39 | color: $subtext 40 | 41 | .speedtest-text 42 | margin: 0 43 | font-size: 28pt 44 | font-weight: 700 45 | color: $subtext 46 | 47 | .tooltip-element 48 | position: relative 49 | 50 | .tooltip-element .tooltip 51 | user-select: none 52 | pointer-events: none 53 | font-size: 16pt 54 | opacity: 0 55 | bottom: 120% 56 | left: 50% 57 | margin-left: -60px 58 | background-color: $dark-gray 59 | color: $subtext 60 | text-align: center 61 | border-radius: 6px 62 | padding: 5px 10px 63 | height: fit-content 64 | 65 | z-index: 90 66 | position: absolute 67 | transition: opacity 0.2s 68 | 69 | .tooltip-bottom .tooltip 70 | bottom: 0 71 | top: 120% 72 | 73 | .tooltip-element .tooltip-invisible 74 | visibility: hidden 75 | 76 | .tooltip-element:hover .tooltip 77 | opacity: 1 78 | 79 | @media (max-width: 900px) 80 | .tooltip-element .tooltip 81 | font-size: 14pt 82 | 83 | @media (max-width: 730px) 84 | .tooltip-element .tooltip 85 | font-size: 11pt 86 | 87 | @media (max-width: 605px) 88 | .speedtest 89 | flex-direction: column 90 | .date-text 91 | font-size: 32pt 92 | .speedtest-text 93 | font-size: 32pt 94 | 95 | @media (max-width: 475px) 96 | .tooltip-element .tooltip 97 | font-size: 12pt 98 | padding: 5px 4px -------------------------------------------------------------------------------- /client/src/pages/Home/components/Speedtest/utils/errors.js: -------------------------------------------------------------------------------- 1 | import {t} from "i18next"; 2 | 3 | export const errors = () => ({ 4 | "Network unreachable": t("errors.network_unreachable"), 5 | "Timeout occurred in connect": t("errors.took_too_long"), 6 | "permission denied": t("errors.no_permission"), 7 | "Resource temporarily unavailable": t("errors.resource_unavailable"), 8 | "No route to host": t("errors.no_route"), 9 | "Connection refused": t("errors.connection_refused"), 10 | "timed out": t("errors.timed_out"), 11 | "Could not retrieve or read configuration": t("errors.config"), 12 | }); -------------------------------------------------------------------------------- /client/src/pages/Home/components/Speedtest/utils/infos.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Trans} from "react-i18next"; 3 | import {t} from "i18next"; 4 | 5 | const RESULT_URL = "https://www.speedtest.net/result/c/"; 6 | 7 | export const averageResultDialog = (timeString, props) => }} 8 | values={{amount: props.amount, date: timeString, down: props.down, 9 | up: props.up, duration: props.duration}}>test.average.description 10 | 11 | export const resultDialog = (props) => , 12 | Link: (props.resultId ? : ) }} 13 | values={{down: props.down, up: props.up, 14 | type: t("test.result." + (props.type === "custom" ? "from_you" : "automatic")), 15 | duration: props.duration}}>test.result.description -------------------------------------------------------------------------------- /client/src/pages/Home/components/TestArea/index.js: -------------------------------------------------------------------------------- 1 | export {default} from './TestAreaComponent'; -------------------------------------------------------------------------------- /client/src/pages/Home/components/TestArea/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .error-text 4 | margin: 0 5 | font-size: 26pt 6 | font-weight: 700 7 | color: $subtext 8 | 9 | @media (max-width: 605px) 10 | .error-text 11 | text-align: center 12 | width: 28rem 13 | 14 | @media (max-width: 475px) 15 | .error-text 16 | width: 20rem -------------------------------------------------------------------------------- /client/src/pages/Home/index.js: -------------------------------------------------------------------------------- 1 | export {default} from './Home'; -------------------------------------------------------------------------------- /client/src/pages/Loading/Loading.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | 3 | export const Loading = () => ( 4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | ); -------------------------------------------------------------------------------- /client/src/pages/Loading/index.js: -------------------------------------------------------------------------------- 1 | export {Loading as default} from "./Loading"; -------------------------------------------------------------------------------- /client/src/pages/Loading/styles.sass: -------------------------------------------------------------------------------- 1 | .loading 2 | width: 100vw 3 | height: 100vh 4 | position: absolute 5 | display: flex 6 | justify-content: center 7 | align-items: center -------------------------------------------------------------------------------- /client/src/pages/Nodes/Nodes.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import NodeHeader from "@/pages/Nodes/components/NodeHeader"; 3 | import NodeContainer from "@/pages/Nodes/components/NodeContainer"; 4 | import {useContext, useEffect, useState} from "react"; 5 | import {NodeContext} from "@/common/contexts/Node"; 6 | import {t} from "i18next"; 7 | import CreateNodeDialog from "@/pages/Nodes/components/CreateNodeDialog"; 8 | import {ConfigContext} from "@/common/contexts/Config"; 9 | import {InputDialogContext} from "@/common/contexts/InputDialog"; 10 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 11 | import {faPlus} from "@fortawesome/free-solid-svg-icons"; 12 | 13 | export const Nodes = () => { 14 | const [config] = useContext(ConfigContext); 15 | const [nodes, updateNodes] = useContext(NodeContext); 16 | const [setDialog] = useContext(InputDialogContext); 17 | const [createDialogOpen, setCreateDialogOpen] = useState(false); 18 | 19 | const openPreviewInfoDialog = () => { 20 | setDialog({title: t("preview.title"), description: t("nodes.preview_active"), buttonText: t("dialog.close")}); 21 | } 22 | 23 | useEffect(() => { 24 | updateNodes(); 25 | }, []); 26 | 27 | return ( 28 |
29 | {createDialogOpen && setCreateDialogOpen(false)}/>} 30 | 31 |
32 | 33 | 34 | {nodes.map(node => )} 35 | 36 |
config.previewMode 37 | ? openPreviewInfoDialog() : setCreateDialogOpen(true)}> 38 | 39 |

{t("nodes.add")}

40 |
41 |
42 |
43 | ) 44 | } -------------------------------------------------------------------------------- /client/src/pages/Nodes/components/CreateNodeDialog/index.js: -------------------------------------------------------------------------------- 1 | export {CreateNodeDialog as default} from './CreateNodeDialog'; -------------------------------------------------------------------------------- /client/src/pages/Nodes/components/CreateNodeDialog/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .server-input 4 | font-size: 18pt 5 | padding: 15px 6 | font-weight: 700 7 | margin-top: 15px 8 | background-color: $darker-gray 9 | border: 1px solid $light-gray 10 | color: $subtext 11 | border-radius: 15px 12 | 13 | .server-dialog 14 | transition: all 0.3s ease-in-out 15 | padding-left: 1rem 16 | padding-right: 1rem 17 | padding-top: 1rem 18 | display: flex 19 | flex-direction: column 20 | 21 | .server-group 22 | display: flex 23 | flex-direction: column 24 | align-items: flex-start 25 | margin: 0 0 15px 0 26 | 27 | .server-group h2 28 | margin: 0 29 | color: $green 30 | font-weight: 700 31 | display: flex 32 | align-items: center 33 | gap: 0.5rem 34 | 35 | .server-error 36 | .server-input 37 | border: 1px solid $red 38 | color: $red 39 | 40 | @media screen and (max-width: 425px) 41 | .server-dialog 42 | padding-left: 0.5rem 43 | padding-right: 0.5rem 44 | .server-group h2 45 | font-size: 16pt 46 | .server-input 47 | font-size: 16pt -------------------------------------------------------------------------------- /client/src/pages/Nodes/components/NodeContainer/index.js: -------------------------------------------------------------------------------- 1 | export {NodeContainer as default} from "./NodeContainer"; -------------------------------------------------------------------------------- /client/src/pages/Nodes/components/NodeContainer/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .node-item 4 | border: 2px solid $light-gray 5 | background-color: $dark-gray 6 | border-radius: 15px 7 | display: flex 8 | width: 50rem 9 | cursor: pointer 10 | padding: 1rem 1.5rem 11 | justify-content: space-between 12 | align-items: center 13 | user-select: none 14 | 15 | .hover-green:hover 16 | border: 2px solid $green 17 | 18 | .hover-orange:hover 19 | border: 2px solid $orange 20 | 21 | .hover-red:hover 22 | border: 2px solid $red 23 | 24 | .node-info-area 25 | display: flex 26 | align-items: center 27 | gap: 1rem 28 | 29 | .node-info-area svg 30 | font-size: 30pt 31 | 32 | .node-info-area h1 33 | margin: 0 34 | font-size: 16pt 35 | 36 | .node-info-area p 37 | margin: 0 38 | font-size: 11pt 39 | color: $subtext 40 | 41 | .node-info 42 | display: flex 43 | flex-direction: column 44 | align-items: flex-start 45 | 46 | .node-info * 47 | max-width: 11rem 48 | text-overflow: ellipsis 49 | overflow: hidden 50 | white-space: nowrap 51 | 52 | .speed-area 53 | display: flex 54 | gap: 1rem 55 | 56 | .icon-text 57 | display: flex 58 | gap: 0.5rem 59 | align-items: center 60 | 61 | .icon-text h2 62 | margin: 0 63 | color: $subtext 64 | 65 | .speed-item 66 | display: flex 67 | gap: 0.5rem 68 | 69 | .speed-item svg 70 | font-size: 24pt 71 | 72 | .speed-item h1 73 | margin: 0 74 | font-size: 20pt 75 | color: $subtext 76 | 77 | .speed-icon 78 | font-size: 28pt 79 | 80 | @media screen and (max-width: 862px) 81 | .node-item 82 | width: calc(20vw + 8rem) 83 | flex-direction: column 84 | gap: 1rem 85 | 86 | .speed-area 87 | flex-direction: column 88 | gap: 0.5rem 89 | .icon-text h2 90 | font-size: 14pt -------------------------------------------------------------------------------- /client/src/pages/Nodes/components/NodeHeader/NodeHeader.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | 3 | export const NodeHeader = () => { 4 | return ( 5 |
6 | Logo 7 |

MySpeed

8 |
9 | ) 10 | } -------------------------------------------------------------------------------- /client/src/pages/Nodes/components/NodeHeader/index.js: -------------------------------------------------------------------------------- 1 | export {NodeHeader as default} from "./NodeHeader"; -------------------------------------------------------------------------------- /client/src/pages/Nodes/components/NodeHeader/styles.sass: -------------------------------------------------------------------------------- 1 | .node-header 2 | display: flex 3 | align-items: center 4 | gap: 1rem 5 | 6 | .node-header img 7 | width: 5.3rem 8 | height: 5.3rem 9 | border-radius: 50% 10 | 11 | .node-header h1 12 | font-size: 38pt 13 | font-weight: 700 14 | margin: 0 -------------------------------------------------------------------------------- /client/src/pages/Nodes/index.js: -------------------------------------------------------------------------------- 1 | export {Nodes as default} from './Nodes'; -------------------------------------------------------------------------------- /client/src/pages/Nodes/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .node-page 4 | min-height: 100vh 5 | display: flex 6 | flex-direction: column 7 | justify-content: center 8 | align-items: center 9 | color: $white 10 | text-align: center 11 | margin: 0 12 | gap: 0.7rem 13 | 14 | .node-page hr 15 | margin: 0 16 | 17 | .node-area 18 | display: flex 19 | flex-direction: column 20 | gap: 1.5rem 21 | margin-top: 1rem 22 | 23 | .node-add 24 | width: 53rem 25 | display: flex 26 | justify-content: center 27 | align-items: center 28 | border: 2px solid $light-gray 29 | background-color: $dark-gray 30 | border-radius: 15px 31 | cursor: pointer 32 | user-select: none 33 | gap: 0.5rem 34 | 35 | .node-add h1 36 | font-size: 22pt 37 | font-weight: 700 38 | color: $subtext 39 | margin-right: 1rem 40 | 41 | .node-add svg 42 | font-size: 22pt 43 | margin-left: 1rem 44 | 45 | .node-add:hover 46 | border: 2px solid $green 47 | 48 | h1 49 | color: $white 50 | 51 | .node-disabled 52 | width: 53rem 53 | border: 2px dashed $light-gray 54 | border-radius: 15px 55 | cursor: not-allowed 56 | user-select: none 57 | 58 | .node-disabled:hover 59 | border: 2px dashed $light-gray 60 | 61 | h1 62 | color: $subtext 63 | 64 | @media screen and (max-width: 862px) 65 | .node-add 66 | width: calc(20vw + 11rem) 67 | border-radius: 15px 68 | cursor: pointer 69 | 70 | .node-add h1 71 | font-size: 22pt -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/AverageChart/AverageChart.jsx: -------------------------------------------------------------------------------- 1 | import StatisticContainer from "@/pages/Statistics/components/StatisticContainer"; 2 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 3 | import {faGauge, faMinusCircle, faPlusCircle} from "@fortawesome/free-solid-svg-icons"; 4 | import {t} from "i18next"; 5 | import "./styles.sass"; 6 | 7 | export const AverageChart = (props) => { 8 | 9 | return ( 10 | 11 |
12 |
13 |
14 |

{t("statistics.values.min")}

15 |

{props.data.min} {t("latest.speed_unit")}

16 |
17 | 18 |
19 |
20 |
21 |

{t("statistics.values.max")}

22 |

{props.data.max} {t("latest.speed_unit")}

23 |
24 | 25 |
26 |
27 |
28 |

{t("statistics.values.avg")}

29 |

{props.data.avg} {t("latest.speed_unit")}

30 |
31 | 32 |
33 |
34 |
35 | ); 36 | 37 | } -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/AverageChart/index.js: -------------------------------------------------------------------------------- 1 | export {AverageChart as default} from './AverageChart'; -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/AverageChart/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .value-container 4 | display: flex 5 | flex-direction: column 6 | gap: 2rem 7 | justify-content: center 8 | width: 100% 9 | 10 | .value-item 11 | display: flex 12 | justify-content: space-between 13 | 14 | .value-item svg 15 | width: 2.5rem 16 | height: 2.5rem 17 | color: $green 18 | 19 | .value-item .value-info h2 20 | font-size: 16pt 21 | color: $white 22 | margin: 0 23 | 24 | .value-item .value-info p 25 | color: $green 26 | margin: 0 27 | font-weight: 600 -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/DurationChart.jsx: -------------------------------------------------------------------------------- 1 | import StatisticContainer from "@/pages/Statistics/components/StatisticContainer"; 2 | import {Bar} from "react-chartjs-2"; 3 | import {useEffect, useState} from "react"; 4 | import {t} from "i18next"; 5 | 6 | const chartOptions = { 7 | plugins: { 8 | legend: { 9 | display: false 10 | } 11 | }, 12 | scales: { 13 | x: { 14 | ticks: { 15 | color: "#B0B0B0", 16 | } 17 | }, 18 | y: { 19 | ticks: { 20 | color: "#B0B0B0", 21 | }, 22 | beginAtZero: true, 23 | } 24 | } 25 | } 26 | 27 | const DurationChart = (props) => { 28 | 29 | const chartData = { 30 | labels: [], 31 | datasets: [{ 32 | label: t("statistics.duration.label"), 33 | data: [], 34 | backgroundColor: ['#45C65A', '#456AC6', '#C64545', '#C6C645', '#C645C6'], 35 | borderWidth: 0, 36 | }], 37 | }; 38 | 39 | const [data, setData] = useState({}); 40 | 41 | useEffect(() => { 42 | if (!props.time) return; 43 | 44 | const frequencies = {}; 45 | const tempData = {...chartData}; 46 | props.time.forEach(second => { 47 | frequencies[second] ? frequencies[second]++ : frequencies[second] = 1; 48 | }); 49 | 50 | for (let second in frequencies) { 51 | tempData.labels.push(t("time.seconds", {replace: {seconds: second.toString()}})); 52 | tempData.datasets[0].data.push(frequencies[second]); 53 | } 54 | 55 | setData(tempData); 56 | }, [props.time]); 57 | 58 | if (Object.keys(data).length === 0) return <>; 59 | 60 | return ( 61 | 62 | 63 | 64 | ); 65 | 66 | } 67 | 68 | export default DurationChart; -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/FailedChart.jsx: -------------------------------------------------------------------------------- 1 | import {Doughnut} from "react-chartjs-2"; 2 | import StatisticContainer from "@/pages/Statistics/components/StatisticContainer"; 3 | import {t} from "i18next"; 4 | 5 | const chartOptions = { 6 | plugins: { 7 | legend: { 8 | display: false 9 | } 10 | }, 11 | responsive: true, 12 | cutout: 80 13 | }; 14 | 15 | const FailedChart = (props) => { 16 | 17 | const chartData = { 18 | labels: [t("statistics.failed.success"), t("statistics.failed.failed")], 19 | datasets: [{ 20 | label: t("statistics.failed.label"), 21 | data: [props.tests.total - props.tests.failed, props.tests.failed], 22 | backgroundColor: ['#456AC6', '#C64545'], 23 | borderWidth: 0 24 | }] 25 | }; 26 | 27 | return ( 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | 35 | export default FailedChart; -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/LatestTestChart/index.js: -------------------------------------------------------------------------------- 1 | export {LatestTestChart as default} from "./LatestTestChart"; -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/LatestTestChart/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .info-container 4 | display: flex 5 | flex-direction: column 6 | gap: 2rem 7 | justify-content: center 8 | width: 100% 9 | 10 | .test-container 11 | display: flex 12 | justify-content: space-between 13 | 14 | .test-container svg 15 | width: 3rem 16 | height: 3rem 17 | 18 | .test-container .test-info h2 19 | color: $white 20 | margin: 0 21 | 22 | .test-container .test-info p 23 | margin: 0 24 | font-weight: 600 -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/ManualChart.jsx: -------------------------------------------------------------------------------- 1 | import {Doughnut} from "react-chartjs-2"; 2 | import StatisticContainer from "@/pages/Statistics/components/StatisticContainer"; 3 | import {t} from "i18next"; 4 | 5 | const chartOptions = { 6 | plugins: { 7 | legend: { 8 | display: false 9 | } 10 | }, 11 | cutout: 80 12 | }; 13 | 14 | const ManuelChart = (props) => { 15 | 16 | const chartData = { 17 | labels: [t("statistics.manual.yes"), t("statistics.manual.no")], 18 | datasets: [{ 19 | label: t("statistics.failed.label"), 20 | data: [props.tests.custom, props.tests.total - props.tests.custom], 21 | backgroundColor: ['#456AC6', '#45C65A'], 22 | borderWidth: 0 23 | }] 24 | }; 25 | 26 | return ( 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | 34 | export default ManuelChart; -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/OverviewChart/OverviewChart.jsx: -------------------------------------------------------------------------------- 1 | import StatisticContainer from "@/pages/Statistics/components/StatisticContainer"; 2 | import {t} from "i18next"; 3 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 4 | import {faCircleExclamation, faGaugeHigh, faStopwatch} from "@fortawesome/free-solid-svg-icons"; 5 | import "./styles.sass"; 6 | 7 | export const OverviewChart = (props) => { 8 | 9 | const title = t("test.overview.title", {replace: {amount: t("test.overview." + (localStorage.getItem("testTime") || 1))}}); 10 | 11 | const items = [ 12 | { 13 | icon: faGaugeHigh, 14 | title: "statistics.overview.total_title", 15 | description: "statistics.overview.total_description", 16 | value: props.tests.total 17 | }, 18 | { 19 | icon: faCircleExclamation, 20 | title: "statistics.overview.failed_title", 21 | description: "statistics.overview.failed_description", 22 | value: props.tests.failed 23 | }, 24 | { 25 | icon: faStopwatch, 26 | title: "statistics.overview.average_title", 27 | description: "statistics.overview.average_description", 28 | value: props.time.avg + "s" 29 | } 30 | ]; 31 | 32 | return ( 33 | 34 |
35 | {items.map((item, index) => ( 36 |
37 |
38 | 39 |
40 |

{t(item.title)}

41 |

{t(item.description)}

42 |
43 |
44 |

{item.value}

45 |
46 | ))} 47 |
48 |
49 | ); 50 | 51 | } -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/OverviewChart/index.js: -------------------------------------------------------------------------------- 1 | export {OverviewChart as default} from "./OverviewChart"; -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/OverviewChart/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .overview-items 4 | display: flex 5 | justify-content: center 6 | flex-direction: column 7 | width: 100% 8 | overflow-y: clip 9 | 10 | .overview-item 11 | display: flex 12 | justify-content: space-between 13 | 14 | .overview-item .info-area 15 | display: flex 16 | justify-content: center 17 | align-content: center 18 | align-items: center 19 | gap: 1rem 20 | 21 | & svg 22 | width: 2.5rem 23 | height: 2.5rem 24 | color: $white 25 | 26 | .overview-item .info-area .text-area 27 | & h2 28 | margin: 0 29 | color: $white 30 | & p 31 | margin: 0 32 | font-weight: 500 33 | color: $subtext 34 | 35 | .overview-item h2 36 | color: $green 37 | 38 | @media screen and (max-width: 1500px) 39 | .overview-item 40 | flex-wrap: wrap 41 | flex-direction: row 42 | 43 | @media screen and (max-width: 1400px) 44 | .info-area svg 45 | display: none 46 | .overview-item .info-area h2 47 | display: inline-block 48 | width: 15rem 49 | white-space: nowrap 50 | overflow: hidden !important 51 | text-overflow: ellipsis 52 | .overview-item .info-area .text-area p 53 | display: none 54 | 55 | @media screen and (max-width: 1000px) 56 | .overview-item .info-area h2 57 | width: 10rem -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/PingChart.jsx: -------------------------------------------------------------------------------- 1 | import {Line} from "react-chartjs-2"; 2 | import StatisticContainer from "@/pages/Statistics/components/StatisticContainer"; 3 | import {t} from "i18next"; 4 | 5 | const chartOptions = { 6 | plugins: { 7 | tooltip: { 8 | callbacks: { 9 | label: (item) => item.dataset.label + ": " + item.formattedValue + " " + t("latest.ping_unit") 10 | } 11 | }, 12 | legend: { 13 | display: false 14 | } 15 | }, 16 | scales: {x: {reverse: true}}, 17 | responsive: true 18 | }; 19 | 20 | const SpeedChart = (props) => { 21 | const testTime = localStorage.getItem("testTime") || 1; 22 | const chartData = { 23 | labels: testTime < 3 ? props.labels.map((label) => new Date(label).toLocaleTimeString([], 24 | {hour: "2-digit", minute: "2-digit"})) : props.labels.slice(1).map((label) => 25 | new Date(label).toLocaleDateString()), 26 | datasets: [ 27 | { 28 | label: t("latest.ping"), 29 | data: testTime < 3 ? props.data.ping : props.data.ping.slice(1), 30 | borderColor: '#45C65A', 31 | }, 32 | ], 33 | }; 34 | 35 | return ( 36 | 37 | 38 | 39 | ) 40 | 41 | } 42 | export default SpeedChart; -------------------------------------------------------------------------------- /client/src/pages/Statistics/charts/SpeedChart.jsx: -------------------------------------------------------------------------------- 1 | import {Line} from "react-chartjs-2"; 2 | import StatisticContainer from "@/pages/Statistics/components/StatisticContainer"; 3 | import {t} from "i18next"; 4 | 5 | const chartOptions = { 6 | plugins: { 7 | tooltip: { 8 | callbacks: { 9 | label: (item) => item.dataset.label + ": " + item.formattedValue + " " + t("latest.speed_unit") 10 | } 11 | }, 12 | legend: { 13 | position: "bottom" 14 | } 15 | }, 16 | scales: {x: {reverse: true}} 17 | }; 18 | 19 | const SpeedChart = (props) => { 20 | const testTime = localStorage.getItem("testTime") || 1; 21 | const chartData = { 22 | labels: testTime < 3 ? props.labels.map((label) => new Date(label).toLocaleTimeString([], 23 | {hour: "2-digit", minute: "2-digit"})) : props.labels.slice(1).map((label) => 24 | new Date(label).toLocaleDateString()), 25 | datasets: [ 26 | { 27 | label: t("latest.down"), 28 | data: testTime < 3 ? props.data.download : props.data.download.slice(1), 29 | borderColor: '#45C65A' 30 | }, 31 | { 32 | label: t("latest.up"), 33 | data: testTime < 3 ? props.data.upload : props.data.upload.slice(1), 34 | borderColor: '#456AC6' 35 | }, 36 | ], 37 | }; 38 | 39 | return ( 40 | 41 | 42 | 43 | ) 44 | 45 | } 46 | export default SpeedChart; -------------------------------------------------------------------------------- /client/src/pages/Statistics/components/StatisticContainer/StatisticContainer.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | 3 | export const StatisticContainer = (props) => { 4 | 5 | return ( 6 |
7 |
8 | {props.title} 9 |
10 |
11 | {props.children} 12 |
13 |
14 | ); 15 | } -------------------------------------------------------------------------------- /client/src/pages/Statistics/components/StatisticContainer/index.js: -------------------------------------------------------------------------------- 1 | export {StatisticContainer as default} from './StatisticContainer'; -------------------------------------------------------------------------------- /client/src/pages/Statistics/components/StatisticContainer/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .stats-container 4 | display: flex 5 | flex-direction: column 6 | min-width: 16rem 7 | border: 2px solid $light-gray 8 | background-color: $dark-gray 9 | border-radius: 1rem 10 | cursor: pointer 11 | transition: all 0.2s ease-in-out 12 | animation: 0.3s fadeIn 13 | flex: 1 0 15% 14 | 15 | .stats-container:hover 16 | border: 2px solid $green 17 | transform: scale(1.05) 18 | 19 | .stats-header 20 | color: $white 21 | font-size: 16pt 22 | border-radius: 1rem 1rem 0 0 23 | padding: 0.75rem 0.5rem 0.5rem 1rem 24 | 25 | .stats-content 26 | display: flex 27 | padding-bottom: 1rem 28 | padding-left: 1.5rem 29 | height: 14rem 30 | padding-right: 1.5rem 31 | 32 | .container-center 33 | justify-content: center 34 | align-items: center 35 | 36 | .container-small 37 | flex: 1 0 5% 38 | 39 | .container-normal 40 | flex: 1 0 30% 41 | 42 | .container-large 43 | flex: 1 0 35% -------------------------------------------------------------------------------- /client/src/pages/Statistics/index.js: -------------------------------------------------------------------------------- 1 | export {Statistics as default} from "./Statistics"; -------------------------------------------------------------------------------- /client/src/pages/Statistics/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .statistic-area 4 | margin-left: 20rem 5 | margin-right: 20rem 6 | padding-top: 1.5rem 7 | margin-bottom: 2rem 8 | display: flex 9 | gap: 2rem 10 | justify-content: space-between 11 | flex-wrap: wrap 12 | transition: 1s all ease-in-out 13 | 14 | @media screen and (max-width: 1472px) 15 | .statistic-area 16 | margin-left: 10rem 17 | margin-right: 10rem 18 | 19 | @media screen and (max-width: 425px) 20 | .statistic-area 21 | margin-left: 3rem 22 | margin-right: 3rem 23 | 24 | @media screen and (max-width: 375px) 25 | .statistic-area 26 | margin-left: 1rem 27 | margin-right: 1rem -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import {VitePWA} from "vite-plugin-pwa"; 4 | import * as path from "path"; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | VitePWA({injectRegister: "auto", manifest: false}), 9 | react() 10 | ], 11 | build: { 12 | outDir: "build", 13 | chunkSizeWarningLimit: 1600, 14 | rollupOptions: { 15 | output: { 16 | manualChunks(id) { 17 | if (id.includes('node_modules')) 18 | return id.includes('@fortawesome') ? 'icons' : 'vendor'; 19 | } 20 | } 21 | } 22 | }, 23 | resolve: { 24 | alias: { 25 | "@": path.resolve(__dirname, "./src"), 26 | }, 27 | }, 28 | server: { 29 | proxy: { 30 | "/api": "http://localhost:5216/" 31 | } 32 | } 33 | }); -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: /client/public/assets/locales/en.json 3 | translation: /client/public/assets/locales/%two_letters_code%.json 4 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | docs.myspeed.dev -------------------------------------------------------------------------------- /docs/assets/images/de/integrations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/de/integrations.png -------------------------------------------------------------------------------- /docs/assets/images/de/interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/de/interface.png -------------------------------------------------------------------------------- /docs/assets/images/de/latest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/de/latest.png -------------------------------------------------------------------------------- /docs/assets/images/de/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/de/settings.png -------------------------------------------------------------------------------- /docs/assets/images/de/tests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/de/tests.png -------------------------------------------------------------------------------- /docs/assets/images/de/view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/de/view.png -------------------------------------------------------------------------------- /docs/assets/images/en/integrations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/en/integrations.png -------------------------------------------------------------------------------- /docs/assets/images/en/interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/en/interface.png -------------------------------------------------------------------------------- /docs/assets/images/en/latest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/en/latest.png -------------------------------------------------------------------------------- /docs/assets/images/en/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/en/settings.png -------------------------------------------------------------------------------- /docs/assets/images/en/tests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/en/tests.png -------------------------------------------------------------------------------- /docs/assets/images/en/view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/en/view.png -------------------------------------------------------------------------------- /docs/assets/images/latest_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/latest_test.png -------------------------------------------------------------------------------- /docs/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/docs/assets/images/logo.png -------------------------------------------------------------------------------- /docs/index.en.md: -------------------------------------------------------------------------------- 1 | # MySpeed Documentation 2 | 3 | This documentation explains how to install, configure and use MySpeed on a server of your choice. 4 | 5 | !!! note "" 6 | Did you find another suggestion or bug? Then open an [Issue](https://github.com/gnmyt/myspeed/issues) or write me a 7 | message on [Discord (@GNM)](https://discord.com/users/386242172632170496) 8 | 9 | ## What is it? 10 | 11 | MySpeed is a speed test analysis software that stores the speed of your internet for up to 30 days. This can also be 12 | useful if you want to know when your network might have drops or if you want to check if your internet matches the 13 | booked values from your contract. 14 | 15 | It is recommended to install the software at home. Of course, it can also be installed on a server in a data center, if 16 | you wish ;) 17 | 18 | ## How does it work? 19 | 20 | MySpeed automatically creates tests every hour, which you can view in a dashboard. If a test is older than 30 days, it 21 | will be deleted to keep the dashboard clearer. 22 | 23 | ## What do I need? 24 | 25 | In summary, just a device that is capable of running **24/7** and is supported by **NodeJS**. This includes Windows, 26 | macOS or Linux, among others. 27 | 28 | Even though it is possible to install the system on a normal computer, it is recommended to install it on a device that 29 | consumes little power. Perfect for this would be for example a so-called [Raspberry Pi](https://www.raspberrypi.com/) or 30 | other [Mini-PCs](https://www.amazon.de/s?k=mini+pc). 31 | 32 | ## :heart: And a big thanks to ... 33 | 34 | - [Sierra Devoplers](https://sierra-dev.de/myspeed) for the page created there 35 | - [Ookla](https://www.ookla.com/) for the test data and the great test network 36 | - [All translators](https://crowdin.com/project/myspeed) for providing it in different languages 37 | - [All contributors](https://github.com/gnmyt/myspeed/graphs/contributors) for their help and work into the project -------------------------------------------------------------------------------- /docs/instructions/main.de.md: -------------------------------------------------------------------------------- 1 | # Die Oberfläche 2 | 3 | Die Oberfläche von MySpeed ist vom Grund auf so konzipiert, dass sie einfach zu bedienen ist. Um mögliche Verwirrungen 4 | jedoch zu vermeiden, wird alles hier noch einmal erklärt. 5 | 6 | ## Die Oberfläche 7 | 8 | ![MySpeed Oberfläche](/assets/images/de/interface.png){: align="left" } 9 | 10 | Die Oberfläche von MySpeed ist in **3** Teile unterteilt. 11 | 12 | Der **erste** Teil ist der sogenannte Header und enthält einen direkten Zugriff auf die Einstellungen und das Starten 13 | eines Speedtests. Auf die Einstellungen wird [hier](../settings) genauer eingegangen. 14 | 15 | Im **zweiten** Teil wird immer der aktuellste Speedtest angezeigt. 16 | 17 | Der **dritte** Teil zeigt alle erstellten Speedtests an. 18 | 19 |
20 | 21 | ## Letzter Test 22 | ![Letzter Test](/assets/images/de/latest.png){align="left"} 23 | 24 | Dieses Teil zeigt immer den aktuellsten, also zuletzt erstellten Speedtest an. Klickt man auf ein Icon, öffnet sich 25 | das jeweilige Hilfe-Menü mit mehr Informationen 26 | 27 |
28 | 29 | ## Übersicht aller Tests 30 | 31 | ![Letzter Test](/assets/images/de/tests.png){align="left"} 32 | 33 | Hier lassen sich alle Tests anzeigen, die erstellt wurden. Es lässt sich jetzt auch auf die Uhr links klicken, um 34 | einen Dialog mit weiteren Informationen zu öffnen. Dieser Dialog zeigt nun weitere Informationen über den Test und 35 | bietet sogar die Möglichkeit, ihn direkt zu löschen (falls nötig). 36 | -------------------------------------------------------------------------------- /docs/instructions/main.en.md: -------------------------------------------------------------------------------- 1 | # The interface 2 | 3 | MySpeed's interface is designed from the ground up to be easy to use. However, to avoid possible confusion, 4 | everything is explained here. 5 | 6 | ## The user interface 7 | 8 | ![MySpeed interface](/assets/images/en/interface.png){: align="left" } 9 | 10 | The interface of MySpeed is divided into **3** parts. 11 | 12 | The **first** part is the so-called header and contains direct access to the settings and the start of a speed test. 13 | The settings are described in more detail [here](../settings). 14 | 15 | The **second** part always shows the most recent speedtest. 16 | 17 | The **third** part shows all created speedtests. 18 | 19 |
20 | 21 | ## Last test 22 | ![Last test](/assets/images/en/latest.png){align="left"} 23 | 24 | This part always shows the most recent, i.e. last created speedtest. If you click on an icon, the respective help menu will open 25 | with more information 26 | 27 |
28 | 29 | ## Overview of all tests 30 | 31 | ![Last test](/assets/images/en/tests.png){align="left"} 32 | 33 | Here you can view all tests that have been created. Now you can also click on the clock on the left to open a dialog 34 | with more information. This dialog now shows more information about the test and even offers the possibility to delete 35 | it directly (if necessary). -------------------------------------------------------------------------------- /docs/troubleshooting.de.md: -------------------------------------------------------------------------------- 1 | # Fehlerbehebung 2 | 3 | In diesem Guide erfährst du, wie du bekannte Fehler mit diesem Dienst behebst. 4 | 5 | ??? failure "Could not open the database file. Maybe it is damaged?" 6 | Bei diesem Fehler kann es mehrere Lösungen geben. Arbeite einfach alle Möglichkeiten durch und dein Problem sollte gelöst sein. :) 7 | 8 | 1. Berechtigungen setzen 9 | Um die Berechtigungen zu setzen, gib den Befehl `chmod 700 /opt/myspeed` ein. (Ersetze /opt/myspeed mit deinem Installationsort) 10 | 11 | 2. Build Essentials Installieren 12 | Es ist aktuell möglich, dass der Fehler auftritt, weil das Paket `build-essential` nicht installiert ist. Installiere es ganz einfach mit dem Befehl `sudo apt-get install build-essential` nach. 13 | 14 | 3. Führe eine Neuinstallation der Module aus 15 | Gib zuerst den Befehl `rm -R node_modules` im Installationsordner ein, um die Module zu löschen und installiere sie dann mit `npm install` nach. 16 | 17 | ??? failure "Diese MySpeed-Instanz befindet sich aktuell im Entwicklungsmodus" 18 | Das ist nicht wirklich ein Fehler, lediglich eine Sicherung um das Tool nur in Produktionsumgebungen verwenden zu können. 19 | Setze dazu die Umgebungsvariable `NODE_ENV` auf den Wert `production`. 20 | Unter Linux erreichst du das mit `export NODE_ENV=production` und unter Windows in der Powershell mit `$env:NODE_ENV="production"` 21 | Lies dir auch mal den [Guide zur 24/7 Installation](../setup/linux) durch, 22 | wenn du planst, MySpeed im Hintergrund laufen zu lassen und beim Systemstart automatisch hochzufahren. -------------------------------------------------------------------------------- /docs/troubleshooting.en.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | In this guide you will learn how to fix known errors with this service. 4 | 5 | ??? failure "Could not open the database file. Maybe it is damaged?" 6 | There can be several solutions to this error. Just work through all possibilities and your problem should be solved. :) 7 | 8 | 1. Set the required permissions 9 | To set the permissions, enter the command `chmod 700 /opt/myspeed`. (Replace /opt/myspeed with your installation location). 10 | 11 | 2. Install the build essentials 12 | It is currently possible that the error occurs because the `build-essential` package is not installed. Simply reinstall it with the command `sudo apt-get install build-essential`. 13 | 14 | 3. Perform a new installation of the modules 15 | First enter the command `rm -R node_modules` in the installation folder to delete the modules and then reinstall them with `npm install`. 16 | 17 | ??? failure "This MySpeed instance is currently in development mode" 18 | This is not really an error, just a backup to use the tool only in production environments. 19 | Set the environment variable `NODE_ENV` to the value `production`. 20 | On Linux you can do this with `export NODE_ENV=production` and on Windows in the Powershell with `$env:NODE_ENV="production"`. 21 | Also read the [guide for 24/7 installation](../setup/linux), if you plan to run MySpeed in the background and start it automatically 22 | at system startup. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "myspeed", 3 | "version": "1.0.9", 4 | "scripts": { 5 | "client": "cd client && npm run dev", 6 | "server": "nodemon server", 7 | "build": "cd client && npm run build", 8 | "dev": "concurrently --kill-others-on-fail \"npm run server\" \"npm run client\"" 9 | }, 10 | "dependencies": { 11 | "@resvg/resvg-js": "^2.6.2", 12 | "axios": "^1.9.0", 13 | "bcrypt": "^6.0.0", 14 | "cron-validator": "^1.3.1", 15 | "decompress": "^4.2.1", 16 | "decompress-targz": "^4.1.1", 17 | "decompress-unzip": "^4.0.1", 18 | "express": "^5.1.0", 19 | "mysql2": "^3.14.1", 20 | "node-schedule": "^2.1.1", 21 | "prom-client": "^15.1.3", 22 | "satori": "^0.13.2", 23 | "satori-html": "^0.3.2", 24 | "sequelize": "^6.37.7", 25 | "sqlite3": "^5.1.7", 26 | "tmp": "^0.2.3" 27 | }, 28 | "devDependencies": { 29 | "concurrently": "^9.1.2", 30 | "nodemon": "^3.1.10" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /scripts/chooser.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | GREEN='\033[0;32m' 4 | BLUE='\033[1;34m' 5 | YELLOW='\033[1;33m' 6 | RED='\033[1;31m' 7 | NORMAL='\033[0;39m' 8 | PURPLE='\033[0;35m' 9 | 10 | if [ $EUID -ne 0 ]; then 11 | echo -e "$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-" 12 | echo -e "$RED✗ ABORTED" 13 | echo -e "$NORMAL The installation is currently running via a user without root privileges. However, this is required. Please log in with a Root Account to continue." 14 | echo -e "$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-" 15 | exit 16 | fi 17 | 18 | echo -e "${GREEN}---------${BLUE} MySpeed Installation ${GREEN}---------${NORMAL}" 19 | echo -e "${BLUE}Welcome to MySpeed Installation Script!${NORMAL}" 20 | echo -e "${YELLOW}Do you want to install MySpeed with Docker or the normal installation script?${NORMAL}" 21 | echo -e "${YELLOW}[1]${NORMAL} Docker (Recommended)" 22 | echo -e "${YELLOW}[2]${NORMAL} Normal Install Script" 23 | 24 | read -p "Enter your choice (1/2): " choice 25 | 26 | case $choice in 27 | 1) 28 | echo -e "${BLUE}Running Docker installation script...${NORMAL}" 29 | bash <(curl -sSL https://raw.githubusercontent.com/gnmyt/myspeed/development/scripts/docker-install.sh) 30 | ;; 31 | 2) 32 | echo -e "${BLUE}Running normal installation script...${NORMAL}" 33 | bash <(curl -sSL https://raw.githubusercontent.com/gnmyt/myspeed/development/scripts/install.sh) 34 | ;; 35 | *) 36 | echo -e "${RED}Invalid choice. Exiting.${NORMAL}" 37 | exit 1 38 | ;; 39 | esac -------------------------------------------------------------------------------- /scripts/docker-install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | GREEN='\033[0;32m' 4 | BLUE='\033[1;34m' 5 | YELLOW='\033[1;33m' 6 | RED='\033[1;31m' 7 | NORMAL='\033[0;39m' 8 | 9 | if [ $EUID -ne 0 ]; then 10 | echo -e "$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-" 11 | echo -e "$RED✗ ABORTED" 12 | echo -e "$NORMAL The installation is currently running via a user without root privileges. However, this is required. Please log in with a Root Account to continue." 13 | echo -e "$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-$RED-$NORMAL-" 14 | exit 15 | fi 16 | 17 | if ! command -v docker &> /dev/null; then 18 | echo -e "${YELLOW}Docker is not installed. Installing Docker...${NORMAL}" 19 | curl -fsSL https://get.docker.com -o get-docker.sh 20 | sh get-docker.sh 21 | rm get-docker.sh 22 | fi 23 | 24 | INSTALLATION_PATH="/opt/myspeed-dockerized" 25 | mkdir -p "$INSTALLATION_PATH" 26 | 27 | echo -e "${BLUE}Creating docker-compose.yml file...${NORMAL}" 28 | cat << EOF > "$INSTALLATION_PATH/docker-compose.yml" 29 | version: '3' 30 | services: 31 | myspeed: 32 | image: germannewsmaker/myspeed 33 | ports: 34 | - "5216:5216" 35 | volumes: 36 | - myspeed:/myspeed/data 37 | restart: unless-stopped 38 | container_name: MySpeed 39 | volumes: 40 | myspeed: 41 | EOF 42 | 43 | echo -e "${GREEN}Starting MySpeed Docker container...${NORMAL}" 44 | cd "$INSTALLATION_PATH" && docker compose up -d 45 | 46 | if [ $? -eq 0 ]; then 47 | echo -e "${GREEN}MySpeed Docker container started successfully.${NORMAL}" 48 | else 49 | echo -e "${RED}Error: Failed to start MySpeed Docker container.${NORMAL}" 50 | exit 1 51 | fi -------------------------------------------------------------------------------- /server/config/binaries.js: -------------------------------------------------------------------------------- 1 | module.exports.ooklaVersion = "1.2.0"; 2 | module.exports.ooklaList = [ 3 | // MacOS 4 | {os: 'darwin', arch: 'x64', suffix: 'macosx-x86_64.tgz'}, 5 | 6 | // Windows 7 | {os: 'win32', arch: 'x64', suffix: 'win64.zip'}, 8 | 9 | // Linux 10 | {os: 'linux', arch: 'ia32', suffix: 'linux-i386.tgz'}, 11 | {os: 'linux', arch: 'x64', suffix: 'linux-x86_64.tgz'}, 12 | {os: 'linux', arch: 'arm', suffix: 'linux-armhf.tgz'}, 13 | {os: 'linux', arch: 'arm64', suffix: 'linux-aarch64.tgz'}, 14 | 15 | // FreeBSD 16 | {os: 'freebsd', arch: 'x64', suffix: 'freebsd12-x86_64.pkg'} 17 | ]; 18 | 19 | module.exports.libreVersion = "1.0.10"; 20 | module.exports.libreList = [ 21 | // MacOS 22 | {os: 'darwin', arch: 'x64', suffix: 'darwin_amd64.tar.gz'}, 23 | {os: 'darwin', arch: 'arm64', suffix: 'darwin_arm64.tar.gz'}, 24 | 25 | // Windows 26 | {os: 'win32', arch: 'x64', suffix: 'windows_amd64.zip'}, 27 | {os: 'win32', arch: 'ia32', suffix: 'windows_386.zip'}, 28 | {os: 'win32', arch: 'arm64', suffix: 'windows_arm64.zip'}, 29 | 30 | // Linux 31 | {os: 'linux', arch: 'x64', suffix: 'linux_amd64.tar.gz'}, 32 | {os: 'linux', arch: 'ia32', suffix: 'linux_386.tar.gz'}, 33 | {os: 'linux', arch: 'arm', suffix: 'linux_armv7.tar.gz'}, 34 | {os: 'linux', arch: 'arm64', suffix: 'linux_arm64.tar.gz'}, 35 | 36 | // FreeBSD 37 | {os: 'freebsd', arch: 'x64', suffix: 'freebsd_amd64.tar.gz'}, 38 | {os: 'freebsd', arch: 'ia32', suffix: 'freebsd_386.tar.gz'}, 39 | {os: 'freebsd', arch: 'arm', suffix: 'freebsd_armv7.tar.gz'}, 40 | {os: 'freebsd', arch: 'arm64', suffix: 'freebsd_arm64.tar.gz'} 41 | ] -------------------------------------------------------------------------------- /server/config/database.js: -------------------------------------------------------------------------------- 1 | const {Sequelize} = require('sequelize'); 2 | 3 | const STORAGE_PATH = `data/storage${process.env.PREVIEW_MODE === "true" ? "_preview" : ""}.db`; 4 | 5 | Sequelize.DATE.prototype._stringify = () => { 6 | return new Date().toISOString(); 7 | } 8 | 9 | if (process.env.DB_TYPE === "mysql") { 10 | 11 | if (!process.env.DB_NAME || !process.env.DB_PASS || !process.env.DB_USER) 12 | throw new Error("Missing database environment variables"); 13 | 14 | module.exports = new Sequelize(process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASS, { 15 | host: process.env.DB_HOST || "localhost", 16 | dialect: 'mysql', 17 | logging: false, 18 | query: {raw: true} 19 | }); 20 | } else if (!process.env.DB_TYPE || process.env.DB_TYPE === "sqlite") { 21 | module.exports = new Sequelize({dialect: 'sqlite', storage: STORAGE_PATH, logging: false, query: {raw: true}}); 22 | } else { 23 | throw new Error("Invalid database type"); 24 | } -------------------------------------------------------------------------------- /server/controller/node.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const nodes = require('../models/Node'); 3 | 4 | module.exports.listAll = async () => await nodes.findAll() 5 | .then((result) => result.map((node) => ({...node, password: node.password !== null}))); 6 | 7 | module.exports.create = async (name, url, password) => await nodes.create({name: name, url: url, password: password}); 8 | 9 | module.exports.delete = async (nodeId) => await nodes.destroy({where: {id: nodeId}}); 10 | 11 | module.exports.getOne = async (nodeId) => await nodes.findOne({where: {id: nodeId}}); 12 | 13 | module.exports.updateName = async (nodeId, name) => await nodes.update({name: name}, {where: {id: nodeId}}); 14 | 15 | module.exports.updatePassword = async (nodeId, password) => await nodes.update({password: password}, {where: {id: nodeId}}); 16 | 17 | module.exports.checkStatus = async (url, password) => { 18 | if (password === "none") password = undefined; 19 | const api = await axios.get(url + "/api/config", {headers: {password: password}}).catch(() => { 20 | return "INVALID_URL"; 21 | }); 22 | 23 | if (api === "INVALID_URL" || api.status !== 200) return "INVALID_URL"; 24 | 25 | if (!api.data.ping) return "INVALID_URL"; 26 | 27 | if (api.data.viewMode) return "PASSWORD_REQUIRED"; 28 | 29 | return "NODE_VALID"; 30 | } 31 | 32 | module.exports.proxyRequest = async (url, req, res) => { 33 | const response = await axios(url, { 34 | method: req.method, 35 | headers: req.headers, 36 | data: req.method === "GET" ? undefined : JSON.stringify(req.body), 37 | signal: req.signal, 38 | validateStatus: (status) => status >= 200 && status < 400 39 | }).catch(() => "INVALID_URL"); 40 | 41 | if (response === "INVALID_URL") 42 | return res.status(500).json({message: "Internal server error"}); 43 | 44 | if (response.headers["content-disposition"]) 45 | res.setHeader("content-disposition", response.headers["content-disposition"]); 46 | 47 | res.status(response.status).json(response.data); 48 | } -------------------------------------------------------------------------------- /server/controller/pause.js: -------------------------------------------------------------------------------- 1 | let currentState = false; 2 | let updateTimer; 3 | 4 | module.exports.updateState = (newState) => { 5 | this.currentState = newState; 6 | } 7 | 8 | module.exports.resumeIn = (hours) => { 9 | if (/[^0-9]/.test(hours)) return false; 10 | 11 | 12 | if (updateTimer !== null) 13 | clearTimeout(updateTimer); 14 | 15 | this.updateState(true); 16 | updateTimer = setTimeout(() => this.updateState(false), hours * 3600000); // time in hours 17 | 18 | return true; 19 | } 20 | 21 | module.exports.currentState = currentState; -------------------------------------------------------------------------------- /server/controller/recommendations.js: -------------------------------------------------------------------------------- 1 | const recommendations = require('../models/Recommendations'); 2 | const {triggerEvent} = require("./integrations"); 3 | 4 | module.exports.getCurrent = async () => { 5 | return await recommendations.findOne(); 6 | } 7 | 8 | module.exports.update = async (ping, download, upload) => { 9 | const configuration = {ping: Math.round(ping), download: parseFloat(download.toFixed(2)), 10 | upload: parseFloat(upload.toFixed(2))}; 11 | 12 | await recommendations.destroy({truncate: true}); 13 | 14 | triggerEvent("recommendationsUpdated", configuration).then(() => {}); 15 | 16 | return recommendations.create(configuration); 17 | } -------------------------------------------------------------------------------- /server/controller/servers.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | let ooklaServers; 3 | let libreServers; 4 | 5 | module.exports.getLibreServers = () => { 6 | if (libreServers) return libreServers; 7 | 8 | if (fs.existsSync("./data/servers/librespeed.json")) { 9 | libreServers = fs.readFileSync("./data/servers/librespeed.json"); 10 | libreServers = JSON.parse(libreServers); 11 | 12 | return libreServers; 13 | } 14 | 15 | return []; 16 | } 17 | 18 | module.exports.getOoklaServers = () => { 19 | if (ooklaServers) return ooklaServers; 20 | 21 | if (fs.existsSync("./data/servers/ookla.json")) { 22 | ooklaServers = fs.readFileSync("./data/servers/ookla.json"); 23 | ooklaServers = JSON.parse(ooklaServers); 24 | 25 | return ooklaServers; 26 | } 27 | 28 | return []; 29 | } 30 | 31 | module.exports.getByMode = (mode) => { 32 | if (mode === "ookla") return this.getOoklaServers(); 33 | if (mode === "libre") return this.getLibreServers(); 34 | } -------------------------------------------------------------------------------- /server/integrations/discord.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const {replaceVariables} = require("../util/helpers"); 3 | 4 | const defaults = { 5 | finished: ":sparkles: **A speedtest is finished**\n > :ping_pong: `Ping`: %ping% ms\n > :arrow_up: `Upload`: %upload% Mbps\n > :arrow_down: `Download`: %download% Mbps", 6 | failed: ":x: **A speedtest has failed**\n > `Reason`: %error%" 7 | } 8 | 9 | const postWebhook = async (url, username, color, message, triggerActivity) => { 10 | axios.post(url, { 11 | content: null, username, 12 | embeds: [{description: message, color, footer: {text: "MySpeed"}, timestamp: new Date().toISOString()}], 13 | }) 14 | .then(() => triggerActivity()) 15 | .catch(() => triggerActivity(true)); 16 | } 17 | 18 | module.exports = (registerEvent) => { 19 | registerEvent('testFinished', async (integration, data, activity) => { 20 | if (integration.data.send_finished) 21 | await postWebhook(integration.data.url, integration.data.display_name || "MySpeed", 4572762, 22 | replaceVariables(integration.data.finished_message || defaults.finished, data), activity) 23 | }); 24 | 25 | registerEvent('testFailed', async (integration, error, activity) => { 26 | if (integration.data.send_failed) 27 | await postWebhook(integration.data.url, integration.data.display_name || "MySpeed", 12993861, 28 | replaceVariables(integration.data.failed_message || defaults.failed, {error}), activity) 29 | }); 30 | 31 | return { 32 | icon: "fa-brands fa-discord", 33 | fields: [ 34 | {name: "url", type: "text", required: true, regex: /https:\/\/.*discord.com\/api\/webhooks\/\d+\/.+/}, 35 | {name: "display_name", type: "text", required: false}, 36 | {name: "send_finished", type: "boolean", required: false}, 37 | {name: "finished_message", type: "textarea", required: false}, 38 | {name: "send_failed", type: "boolean", required: false}, 39 | {name: "error_message", type: "textarea", required: false} 40 | ] 41 | }; 42 | } -------------------------------------------------------------------------------- /server/integrations/gotify.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const {replaceVariables} = require("../util/helpers"); 3 | 4 | const defaults = { 5 | finished: "A speedtest is finished:\nPing: %ping% ms\nUpload: %upload% Mbps\nDownload: %download% Mbps", 6 | failed: "A speedtest has failed. Reason: %error%" 7 | } 8 | 9 | const postWebhook = async (url, key, triggerActivity, message, priority) => { 10 | axios.post(`${url}/message`, {message, priority: parseInt(priority)}, 11 | {headers: {"Authorization": "Bearer " + key}}) 12 | .then(() => triggerActivity()) 13 | .catch(() => triggerActivity(true)); 14 | } 15 | 16 | module.exports = (registerEvent) => { 17 | registerEvent('testFinished', async (integration, data, activity) => { 18 | if (integration.data.send_finished) 19 | await postWebhook(integration.data.url, integration.data.key, activity, 20 | replaceVariables(integration.data.finished_message || defaults.finished, data), 21 | integration.data.priority); 22 | }); 23 | 24 | registerEvent('testFailed', async (integration, error, activity) => { 25 | if (integration.data.send_failed) 26 | await postWebhook(integration.data.url, integration.data.key, activity, 27 | replaceVariables(integration.data.failed_message || defaults.failed, {error}), 8); 28 | }); 29 | 30 | return { 31 | icon: "fa-solid fa-bell", 32 | fields: [ 33 | {name: "url", type: "text", required: true, regex: /https?:\/\/.+/}, 34 | {name: "key", type: "text", required: true, regex: /^.{15}$/}, 35 | {name: "priority", type: "text", required: true, regex: /^[0-9]$/}, 36 | {name: "send_finished", type: "boolean", required: false}, 37 | {name: "finished_message", type: "textarea", required: false}, 38 | {name: "send_failed", type: "boolean", required: false}, 39 | {name: "error_message", type: "textarea", required: false} 40 | ] 41 | }; 42 | } -------------------------------------------------------------------------------- /server/integrations/healthChecks.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | 3 | const sendPing = async (url, path, error, triggerActivity) => { 4 | if (url == null) return; 5 | if (path) url += "/" + path; 6 | 7 | await axios.post(url, error, {headers: {"user-agent": "MySpeed/HealthAgent"}}) 8 | .then(() => triggerActivity()) 9 | .catch(() => triggerActivity(true)); 10 | } 11 | 12 | module.exports = (registerEvent) => { 13 | registerEvent('minutePassed', async (integration, data, triggerActivity) => { 14 | if (integration.data.url) await sendPing(integration.data.url, undefined, undefined, triggerActivity); 15 | }); 16 | 17 | registerEvent('testFailed', async (integration, error, triggerActivity) => { 18 | if (integration.data.url) await sendPing(integration.data.url, "fail", error, triggerActivity); 19 | }); 20 | 21 | registerEvent('testStarted', async (integration, data, triggerActivity) => { 22 | if (integration.data.url) await sendPing(integration.data.url, "start", data, triggerActivity); 23 | }); 24 | 25 | registerEvent('testFinished', async (integration, data, triggerActivity) => { 26 | if (integration.data.url) await sendPing(integration.data.url, undefined, undefined, triggerActivity); 27 | }); 28 | 29 | return { 30 | icon: "fa-solid fa-heart-pulse", 31 | fields: [ 32 | {name: "url", type: "text", required: true, regex: /https?:\/\/.+/}, 33 | ] 34 | }; 35 | } -------------------------------------------------------------------------------- /server/integrations/pushover.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const {replaceVariables} = require("../util/helpers"); 3 | 4 | const BASE_URL = "https://api.pushover.net/1"; 5 | 6 | const defaults = { 7 | finished: "A speedtest is finished:\nPing: %ping% ms\nUpload: %upload% Mbps\nDownload: %download% Mbps", 8 | failed: "A speedtest has failed. Reason: %error%" 9 | } 10 | 11 | module.exports = (registerEvent) => { 12 | registerEvent('testFinished', async (integration, data, triggerActivity) => { 13 | if (!integration.data.send_finished) return; 14 | 15 | const message = replaceVariables(integration.data.finished_message || defaults.finished, data); 16 | 17 | axios.post(`${BASE_URL}/messages.json`, { 18 | token: integration.data.token, 19 | user: integration.data.user_key, message 20 | }).then(() => triggerActivity()) 21 | .catch(() => triggerActivity(true)); 22 | }); 23 | 24 | registerEvent('testFailed', async (integration, error, triggerActivity) => { 25 | if (!integration.data.send_failed) return; 26 | 27 | const message = replaceVariables(integration.data.error_message || defaults.failed, {error}); 28 | 29 | axios.post(`${BASE_URL}/messages.json`, { 30 | token: integration.data.token, 31 | user: integration.data.user_key, message 32 | }).then(() => triggerActivity()) 33 | .catch(() => triggerActivity(true)); 34 | }); 35 | 36 | return { 37 | icon: "fa-solid fa-pushover", 38 | fields: [ 39 | {name: "token", type: "text", required: true, regex: /^[a-z0-9]{30}$/}, 40 | {name: "user_key", type: "text", required: true, regex: /^[a-z0-9]{30}$/}, 41 | {name: "send_finished", type: "boolean", required: false}, 42 | {name: "finished_message", type: "textarea", required: false}, 43 | {name: "send_failed", type: "boolean", required: false}, 44 | {name: "error_message", type: "textarea", required: false} 45 | ] 46 | }; 47 | } -------------------------------------------------------------------------------- /server/integrations/telegram.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const {replaceVariables} = require("../util/helpers"); 3 | 4 | const defaults = { 5 | finished: "✨ *A speedtest is finished*\n🏓 `Ping`: %ping% ms\n🔼 `Upload`: %upload% Mbps\n🔽 `Download`: %download% Mbps", 6 | failed: "❌ *A speedtest has failed*\n`Reason`: %error%" 7 | } 8 | 9 | const postWebhook = async (token, chatId, message, triggerActivity) => { 10 | axios.post(`https://api.telegram.org/bot${token}/sendMessage`, { 11 | text: message, chat_id: chatId, parse_mode: "markdown" 12 | }) 13 | .then(() => triggerActivity()) 14 | .catch(() => triggerActivity(true)); 15 | } 16 | 17 | module.exports = (registerEvent) => { 18 | registerEvent('testFinished', async (integration, data, activity) => { 19 | if (integration.data.send_finished) 20 | await postWebhook(integration.data.token, integration.data.chat_id, 21 | replaceVariables(integration.data.finished_message || defaults.finished, data), activity) 22 | }); 23 | 24 | registerEvent('testFailed', async (integration, error, activity) => { 25 | if (integration.data.send_failed) 26 | await postWebhook(integration.data.token, integration.data.chat_id, 27 | replaceVariables(integration.data.failed_message || defaults.failed, {error}), activity) 28 | }); 29 | 30 | return { 31 | icon: "fa-brands fa-telegram", 32 | fields: [ 33 | {name: "token", type: "text", required: true, regex: /(\d+):[a-zA-Z0-9_-]+/}, 34 | {name: "chat_id", type: "text", required: true, regex: /\d+/}, 35 | {name: "send_finished", type: "boolean", required: false}, 36 | {name: "finished_message", type: "textarea", required: false}, 37 | {name: "send_failed", type: "boolean", required: false}, 38 | {name: "error_message", type: "textarea", required: false} 39 | ] 40 | }; 41 | } -------------------------------------------------------------------------------- /server/middlewares/error.js: -------------------------------------------------------------------------------- 1 | module.exports = (err, req, res, next) => { 2 | if (!(err instanceof SyntaxError)) return next(); 3 | 4 | res.status(400).json({message: "You need to provide a valid JSON body"}); 5 | } -------------------------------------------------------------------------------- /server/middlewares/password.js: -------------------------------------------------------------------------------- 1 | const config = require('../controller/config'); 2 | const bcrypt = require('bcrypt'); 3 | 4 | module.exports = (allowViewAccess) => async (req, res, next) => { 5 | if (process.env.PREVIEW_MODE === "true") return next(); 6 | 7 | let passwordHash = await config.getValue("password"); 8 | let passwordLevel = await config.getValue("passwordLevel"); 9 | 10 | if (passwordHash === "none") { 11 | req.viewMode = false; 12 | return next(); 13 | } 14 | 15 | if (req.headers.password && bcrypt.compareSync(req.headers.password, passwordHash)) { 16 | req.viewMode = false; 17 | return next(); 18 | } 19 | 20 | if (passwordLevel === "read" && allowViewAccess) { 21 | req.viewMode = true; 22 | return next(); 23 | } 24 | 25 | return res.status(401).json({message: "Please provide the correct password in the header"}); 26 | } -------------------------------------------------------------------------------- /server/middlewares/passwordWrapper.js: -------------------------------------------------------------------------------- 1 | const passwordMiddleware = require('./password'); 2 | 3 | const passwordWrapper = (allowViewAccess, customResponseHandler) => async (req, res, next) => { 4 | // Intercept the response send method 5 | const originalSend = res.send.bind(res); 6 | 7 | res.send = function (body) { 8 | // Check if the status code is 401 and a custom response handler is provided 9 | if (res.statusCode === 401 && typeof customResponseHandler === 'function') { 10 | // The password middleware has returned a 401 status code, call the custom response handler 11 | return customResponseHandler(req, res); 12 | } 13 | // Call the original send method for other statuses 14 | return originalSend(body); 15 | }; 16 | 17 | try { 18 | // Execute the original password middleware 19 | await passwordMiddleware(allowViewAccess)(req, res, next); 20 | } catch (err) { 21 | next(err); 22 | } 23 | }; 24 | 25 | module.exports = passwordWrapper; 26 | -------------------------------------------------------------------------------- /server/models/Config.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require('sequelize'); 2 | const db = require("../config/database"); 3 | 4 | module.exports = db.define("config", { 5 | key: { 6 | type: Sequelize.STRING, 7 | primaryKey: true, 8 | allowNull: false 9 | }, 10 | value: { 11 | type: Sequelize.STRING, 12 | allowNull: false 13 | } 14 | }, {freezeTableName: true, createdAt: false, updatedAt: false}); -------------------------------------------------------------------------------- /server/models/IntegrationData.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require('sequelize'); 2 | const db = require("../config/database"); 3 | 4 | module.exports = db.define("integration_data", { 5 | id: { 6 | type: Sequelize.STRING, 7 | required: true, 8 | primaryKey: true, 9 | defaultValue: () => Math.random().toString(36).substring(2, 15) 10 | }, 11 | displayName: { 12 | type: Sequelize.STRING, 13 | defaultValue: "Untitled" 14 | }, 15 | name: { 16 | type: Sequelize.STRING, 17 | required: true, 18 | }, 19 | data: { 20 | type: Sequelize.JSON, 21 | defaultValue: {}, 22 | }, 23 | lastActivity: { 24 | type: Sequelize.DATE, 25 | required: false 26 | }, 27 | activityFailed: { 28 | type: Sequelize.BOOLEAN, 29 | defaultValue: false 30 | } 31 | }, {freezeTableName: true, createdAt: false, updatedAt: false}); -------------------------------------------------------------------------------- /server/models/Node.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require('sequelize'); 2 | const db = require("../config/database"); 3 | 4 | module.exports = db.define("nodes", { 5 | name: { 6 | type: Sequelize.STRING, 7 | defaultValue: "MySpeed Server" 8 | }, 9 | url: { 10 | type: Sequelize.STRING, 11 | allowNull: false 12 | }, 13 | password: { 14 | type: Sequelize.STRING, 15 | allowNull: true 16 | } 17 | }, {createdAt: false, updatedAt: false}); -------------------------------------------------------------------------------- /server/models/Recommendations.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require('sequelize'); 2 | const db = require("../config/database"); 3 | 4 | module.exports = db.define("recommendations", { 5 | ping: { 6 | type: Sequelize.INTEGER, 7 | allowNull: false 8 | }, 9 | download: { 10 | type: Sequelize.DOUBLE, 11 | allowNull: false 12 | }, 13 | upload: { 14 | type: Sequelize.DOUBLE, 15 | allowNull: false 16 | } 17 | }, {createdAt: false, updatedAt: false}); -------------------------------------------------------------------------------- /server/models/Speedtests.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require('sequelize'); 2 | const db = require("../config/database"); 3 | 4 | module.exports = db.define("speedtests", { 5 | id: { 6 | type: Sequelize.INTEGER, 7 | primaryKey: true, 8 | autoIncrement: true, 9 | }, 10 | serverId: { 11 | type: Sequelize.INTEGER, 12 | defaultValue: 0 13 | }, 14 | ping: { 15 | type: Sequelize.INTEGER, 16 | allowNull: false 17 | }, 18 | download: { 19 | type: Sequelize.DOUBLE, 20 | allowNull: false 21 | }, 22 | upload: { 23 | type: Sequelize.DOUBLE, 24 | allowNull: false 25 | }, 26 | error: { 27 | type: Sequelize.STRING, 28 | allowNull: true 29 | }, 30 | type: { 31 | type: Sequelize.STRING, 32 | defaultValue: "auto" 33 | }, 34 | resultId: { 35 | type: Sequelize.STRING, 36 | allowNull: true 37 | }, 38 | time: { 39 | type: Sequelize.INTEGER, 40 | defaultValue: 0 41 | }, 42 | created: { 43 | type: process.env.DB_TYPE === "mysql" ? Sequelize.STRING : Sequelize.TIME, 44 | defaultValue: Sequelize.NOW 45 | } 46 | }, {createdAt: false, updatedAt: false}); -------------------------------------------------------------------------------- /server/routes/config.js: -------------------------------------------------------------------------------- 1 | const app = require('express').Router(); 2 | const config = require('../controller/config'); 3 | const timer = require('../tasks/timer'); 4 | const password = require('../middlewares/password'); 5 | 6 | app.get("/", password(true), async (req, res) => { 7 | let configValues = {}; 8 | (await config.listAll()).forEach(row => { 9 | if (row.key !== "password" && !(req.viewMode && ["ooklaId", "libreId", "cron", "passwordLevel"].includes(row.key))) 10 | configValues[row.key] = row.value; 11 | }); 12 | configValues['viewMode'] = req.viewMode; 13 | configValues['previewMode'] = process.env.PREVIEW_MODE === "true"; 14 | 15 | if (process.env.PREVIEW_MODE === "true") 16 | configValues['previewMessage'] = String(process.env.PREVIEW_MESSAGE || "The owner of this instance has not provided a message"); 17 | 18 | if (Object.keys(configValues).length === 0) return res.status(404).json({message: "Hmm. There are no config values. Weird..."}); 19 | res.json(configValues); 20 | }); 21 | 22 | app.patch("/:key", password(false), async (req, res) => { 23 | const value = await config.validateInput(req.params.key, req.body?.value); 24 | if (Object.keys(value).length !== 1) return res.status(400).json({message: value}); 25 | 26 | if (!await config.updateValue(req.params.key, value.value)) 27 | return res.status(500).json({message: `Error updating the key '${req.params.key}'`}); 28 | 29 | if (req.params.key === "cron") { 30 | timer.stopTimer(); 31 | timer.startTimer(req.body.value.toString()); 32 | } 33 | 34 | res.json({message: `The key '${req.params.key}' has been successfully updated`}); 35 | }); 36 | 37 | module.exports = app; -------------------------------------------------------------------------------- /server/routes/opengraph.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const app = express.Router(); 3 | const passwordWrapper = require('../middlewares/passwordWrapper'); 4 | const generateOpenGraphImage = require("../controller/opengraph"); 5 | 6 | app.get("/image", passwordWrapper(true, (req, res) => { 7 | // If there is a password set and the user does not want others to view their test data, return the project banner 8 | res.redirect('https://repository-images.githubusercontent.com/478222232/b5331514-aa27-4a56-af3e-c4b25446438d'); 9 | }), async (req, res) => { 10 | 11 | try { 12 | const png = await generateOpenGraphImage(req); 13 | 14 | if (!png) { 15 | return res.status(500).json({ message: "Error fetching test data" }); 16 | } 17 | 18 | res.setHeader("Content-Type", "image/png").status(200).send(png); 19 | } catch (error) { 20 | res.status(500).json({ message: error.message }); 21 | } 22 | }); 23 | 24 | module.exports = app; 25 | -------------------------------------------------------------------------------- /server/routes/recommendations.js: -------------------------------------------------------------------------------- 1 | const app = require('express').Router(); 2 | const recommendations = require('../controller/recommendations'); 3 | const password = require('../middlewares/password'); 4 | 5 | app.get("/", password(false), async (req, res) => { 6 | let currentRecommendations = await recommendations.getCurrent(); 7 | if (currentRecommendations === null) return res.status(501).json({message: "There are no recommendations yet"}); 8 | 9 | return res.json(currentRecommendations); 10 | }); 11 | 12 | module.exports = app; -------------------------------------------------------------------------------- /server/routes/system.js: -------------------------------------------------------------------------------- 1 | const app = require('express').Router(); 2 | const version = require('../../package.json').version; 3 | const remote_url = "https://api.github.com/repos/gnmyt/myspeed/releases/latest"; 4 | const axios = require('axios'); 5 | const password = require('../middlewares/password'); 6 | const serverController = require('../controller/servers'); 7 | const interfaces = require('../util/loadInterfaces'); 8 | 9 | app.get("/version", password(false), async (req, res) => { 10 | if (process.env.PREVIEW_MODE === "true") return res.json({local: version, remote: "0"}); 11 | 12 | try { 13 | res.json({local: version, remote: ((await axios.get(remote_url)).data.tag_name).replace("v", "")}); 14 | } catch (e) { 15 | res.json({local: version, remote: "0"}); 16 | } 17 | }); 18 | 19 | app.get("/server/:provider", password(false), (req, res) => { 20 | if (!["ookla", "libre"].includes(req.params.provider)) 21 | return res.status(400).json({message: "Invalid provider"}); 22 | 23 | res.json(serverController.getByMode(req.params.provider)); 24 | }); 25 | 26 | app.get("/interfaces", password(false), async (req, res) => { 27 | res.json(interfaces.interfaces); 28 | }); 29 | 30 | module.exports = app; -------------------------------------------------------------------------------- /server/tasks/integrations.js: -------------------------------------------------------------------------------- 1 | const schedule = require('node-schedule'); 2 | const {triggerEvent} = require("../controller/integrations"); 3 | 4 | let currentState = "ping"; 5 | let job; 6 | 7 | module.exports.setState = (state = "ping") => { 8 | currentState = state; 9 | } 10 | 11 | module.exports.sendPing = async (type, message) => { 12 | await triggerEvent("minutePassed", {type, message}); 13 | } 14 | 15 | module.exports.sendCurrent = async () => { 16 | if (currentState === "ping") await this.sendPing(); 17 | } 18 | 19 | module.exports.sendError = async (error = "Unknown error") => { 20 | await triggerEvent("testFailed", error); 21 | } 22 | 23 | module.exports.sendRunning = async () => { 24 | await triggerEvent("testStarted"); 25 | } 26 | 27 | module.exports.sendFinished = async (data) => { 28 | await triggerEvent("testFinished", data); 29 | } 30 | 31 | module.exports.startTimer = () => { 32 | job = schedule.scheduleJob('* * * * *', () => this.sendCurrent()); 33 | } 34 | 35 | module.exports.stopTimer = () => { 36 | if (job !== undefined) { 37 | job.cancel(); 38 | job = undefined; 39 | } 40 | } -------------------------------------------------------------------------------- /server/tasks/timer.js: -------------------------------------------------------------------------------- 1 | const pauseController = require('../controller/pause'); 2 | const schedule = require('node-schedule'); 3 | const {isValidCron} = require("cron-validator"); 4 | 5 | let job; 6 | 7 | module.exports.startTimer = (cron) => { 8 | if (!isValidCron(cron)) return; 9 | job = schedule.scheduleJob(cron, () => this.runTask()); 10 | } 11 | 12 | module.exports.runTask = async () => { 13 | if (pauseController.currentState) { 14 | console.warn("Speedtests currently paused. Trying again later..."); 15 | return; 16 | } 17 | 18 | await require('./speedtest').create("auto"); 19 | } 20 | 21 | module.exports.stopTimer = () => { 22 | if (job !== undefined) { 23 | job.cancel(); 24 | job = undefined; 25 | } 26 | } 27 | 28 | module.exports.job = job; -------------------------------------------------------------------------------- /server/util/createFolders.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const neededFolder = ["data", "bin", "data/logs", "data/servers"]; 4 | 5 | neededFolder.forEach(folder => { 6 | if (!fs.existsSync(folder)) { 7 | try { 8 | fs.mkdirSync(folder, {recursive: true}); 9 | } catch (e) { 10 | console.error("Could not create the data folder. Please check the permission"); 11 | process.exit(0); 12 | } 13 | } 14 | }); -------------------------------------------------------------------------------- /server/util/errorHandler.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const filePath = process.cwd() + "/data/logs/error.log"; 3 | 4 | module.exports = (error) => { 5 | const date = new Date().toLocaleString(); 6 | const lineStarter = fs.existsSync(filePath) ? "\n\n" : "# Found a bug? Report it here: https://github.com/gnmyt/myspeed/issues\n\n"; 7 | 8 | console.error("An error occurred: " + error.message); 9 | 10 | fs.writeFile(filePath, lineStarter + "## " + date + "\n" + error, {flag: 'a+'}, err => { 11 | if (err) console.error("Could not save error log file.", error); 12 | 13 | process.exit(1); 14 | }); 15 | } -------------------------------------------------------------------------------- /server/util/helpers.js: -------------------------------------------------------------------------------- 1 | module.exports.replaceVariables = (message, variables) => { 2 | for (const variable in variables) 3 | message = message.replace(`%${variable}%`, variables[variable]); 4 | return message; 5 | } 6 | 7 | module.exports.mapFixed = (entries, type) => ({ 8 | min: Math.min(...entries.map((entry) => entry[type])), 9 | max: Math.max(...entries.map((entry) => entry[type])), 10 | avg: parseFloat((entries.reduce((a, b) => a + b[type], 0) / entries.length).toFixed(2)) 11 | }); 12 | 13 | module.exports.mapRounded = (entries, type) => ({ 14 | min: Math.min(...entries.map((entry) => entry[type])), 15 | max: Math.max(...entries.map((entry) => entry[type])), 16 | avg: Math.round(entries.reduce((a, b) => a + b[type], 0) / entries.length) 17 | }); 18 | 19 | module.exports.calculateTestAverages = (tests) => { 20 | let avgNumbers = {ping: 0, down: 0, up: 0, time: 0}; 21 | 22 | tests.forEach((current) => { 23 | avgNumbers.ping += current.ping; 24 | avgNumbers.down += current.download; 25 | avgNumbers.up += current.upload; 26 | avgNumbers.time += current.time; 27 | }); 28 | 29 | Object.keys(avgNumbers).forEach((key) => { 30 | avgNumbers[key] = avgNumbers[key] / tests.length; 31 | }); 32 | 33 | return avgNumbers; 34 | } -------------------------------------------------------------------------------- /server/util/loadCli.js: -------------------------------------------------------------------------------- 1 | const libreProvider = require('./providers/loadLibre'); 2 | const ooklaProvider = require('./providers/loadOokla'); 3 | 4 | module.exports.load = async () => { 5 | await libreProvider.load(); 6 | await ooklaProvider.load(); 7 | } -------------------------------------------------------------------------------- /server/util/loadServers.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const fs = require('fs'); 3 | 4 | // Load servers from ookla 5 | if (!fs.existsSync("data/servers/ookla.json")) { 6 | let servers = {}; 7 | try { 8 | axios.get("https://www.speedtest.net/api/js/servers?limit=20") 9 | .then(res => res.data) 10 | .then(data => { 11 | data?.forEach(row => { 12 | servers[row.id] = row.name + " (" + row.distance + "km)"; 13 | }); 14 | 15 | try { 16 | fs.writeFileSync("data/servers/ookla.json", JSON.stringify(servers, null, 4)); 17 | } catch (e) { 18 | console.error("Could not save servers file"); 19 | } 20 | }); 21 | } catch (e) { 22 | console.error("Could not get servers"); 23 | } 24 | } 25 | 26 | // Load servers from librespeed 27 | if (!fs.existsSync("data/servers/librespeed.json")) { 28 | let servers = {}; 29 | try { 30 | axios.get("https://librespeed.org/backend-servers/servers.php") 31 | .then(res => res.data) 32 | .then(data => { 33 | data?.forEach(row => { 34 | servers[row.id] = row.name; 35 | }); 36 | try { 37 | fs.writeFileSync("data/servers/librespeed.json", JSON.stringify(servers, null, 4)); 38 | } catch (e) { 39 | console.error("Could not save servers file"); 40 | } 41 | }); 42 | } catch (e) { 43 | console.error("Could not get servers"); 44 | } 45 | } -------------------------------------------------------------------------------- /server/util/providers/loadLibre.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const {get} = require('https'); 3 | const decompress = require("decompress"); 4 | const {file} = require("tmp"); 5 | const decompressTarGz = require('decompress-targz'); 6 | const decompressUnzip = require('decompress-unzip'); 7 | const binaries = require('../../config/binaries'); 8 | 9 | const binaryRegex = /librespeed-cli(.exe)?$/; 10 | const binaryDirectory = __dirname + "/../../../bin/"; 11 | const binaryPath = `${binaryDirectory}/librespeed-cli` + (process.platform === "win32" ? ".exe" : ""); 12 | 13 | const downloadPath = `https://github.com/librespeed/speedtest-cli/releases/download/v${binaries.libreVersion}/librespeed-cli_${binaries.libreVersion}_`; 14 | 15 | module.exports.fileExists = async () => fs.existsSync(binaryPath); 16 | 17 | module.exports.downloadFile = async () => { 18 | const binary = binaries.libreList.find(b => b.os === process.platform && b.arch === process.arch); 19 | 20 | if (!binary) 21 | throw new Error(`Your platform (${process.platform}-${process.arch}) is not supported by the LibreSpeed CLI`); 22 | 23 | await new Promise((resolve) => { 24 | file({postfix: binary.suffix}, async (err, path) => { 25 | const location = await new Promise((resolve) => get(downloadPath + binary.suffix, (res) => { 26 | resolve(res.headers.location); 27 | })); 28 | 29 | get(location, async resp => { 30 | resp.pipe(fs.createWriteStream(path)).on('finish', async () => { 31 | await decompress(path, binaryDirectory, { 32 | plugins: [decompressTarGz(), decompressUnzip()], 33 | filter: file => binaryRegex.test(file.path), 34 | map: file => { 35 | file.path = "librespeed-cli" + (process.platform === "win32" ? ".exe" : ""); 36 | return file; 37 | } 38 | }); 39 | resolve(); 40 | }); 41 | }); 42 | }); 43 | }); 44 | } 45 | 46 | module.exports.load = async () => { 47 | if (!await this.fileExists()) 48 | await this.downloadFile(); 49 | } -------------------------------------------------------------------------------- /server/util/providers/loadOokla.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const {get} = require('https'); 3 | const decompress = require("decompress"); 4 | const {file} = require("tmp"); 5 | const decompressTarGz = require('decompress-targz'); 6 | const decompressUnzip = require('decompress-unzip'); 7 | const binaries = require('../../config/binaries'); 8 | 9 | const binaryRegex = /speedtest(.exe)?$/; 10 | const binaryDirectory = __dirname + "/../../../bin/"; 11 | const binaryPath = `${binaryDirectory}/ookla` + (process.platform === "win32" ? ".exe" : ""); 12 | 13 | const downloadPath = `https://install.speedtest.net/app/cli/ookla-speedtest-${binaries.ooklaVersion}-`; 14 | 15 | module.exports.fileExists = async () => fs.existsSync(binaryPath); 16 | 17 | module.exports.downloadFile = async () => { 18 | const binary = binaries.ooklaList.find(b => b.os === process.platform && b.arch === process.arch); 19 | 20 | if (!binary) 21 | throw new Error(`Your platform (${process.platform}-${process.arch}) is not supported by the Speedtest CLI`); 22 | 23 | await new Promise((resolve) => { 24 | file({postfix: binary.suffix}, async (err, path) => { 25 | get(downloadPath + binary.suffix, async resp => { 26 | resp.pipe(fs.createWriteStream(path)).on('finish', async () => { 27 | await decompress(path, binaryDirectory, { 28 | plugins: [decompressTarGz(), decompressUnzip()], 29 | filter: file => binaryRegex.test(file.path), 30 | map: file => { 31 | file.path = "speedtest" + (process.platform === "win32" ? ".exe" : ""); 32 | return file; 33 | } 34 | }); 35 | resolve(); 36 | }); 37 | }); 38 | }); 39 | }); 40 | } 41 | 42 | module.exports.load = async () => { 43 | if (!await this.fileExists()) 44 | await this.downloadFile(); 45 | } -------------------------------------------------------------------------------- /server/util/providers/parseData.js: -------------------------------------------------------------------------------- 1 | const roundSpeed = (bandwidth) => { 2 | return Math.round(bandwidth / 1250) / 100; 3 | } 4 | 5 | module.exports.parseOokla = (test) => { 6 | let ping = Math.round(test.ping.latency); 7 | let download = roundSpeed(test.download.bandwidth); 8 | let upload = roundSpeed(test.upload.bandwidth); 9 | let time = Math.round((test.download.elapsed + test.upload.elapsed) / 1000); 10 | 11 | return {ping, download, upload, time, resultId: test.result?.id}; 12 | } 13 | 14 | module.exports.parseLibre = (test) => ({...test, ping: Math.round(test.ping), time: Math.round(test.elapsed / 1000), 15 | resultId: null}); 16 | 17 | module.exports.parseCloudflare = (test) => ({...test, time: Math.round(test.elapsed), resultId: null}); 18 | 19 | module.exports.parseData = (provider, data) => { 20 | switch (provider) { 21 | case "ookla": 22 | return this.parseOokla(data); 23 | case "libre": 24 | return this.parseLibre(data); 25 | case "cloudflare": 26 | return this.parseCloudflare(data); 27 | default: 28 | throw {message: "Invalid provider"}; 29 | } 30 | } -------------------------------------------------------------------------------- /web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react/jsx-no-target-blank': 'off', 16 | 'react-refresh/only-export-components': [ 17 | 'warn', 18 | { allowConstantExport: true }, 19 | ], 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | MySpeed - Automated Speedtests 23 | 24 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /web/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@fortawesome/fontawesome-svg-core": "^6.5.1", 14 | "@fortawesome/free-brands-svg-icons": "^6.5.1", 15 | "@fortawesome/free-solid-svg-icons": "^6.5.1", 16 | "@fortawesome/react-fontawesome": "^0.2.0", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "react-router-dom": "^7.5.2", 20 | "sass": "^1.71.1" 21 | }, 22 | "devDependencies": { 23 | "@types/react": "^18.2.56", 24 | "@types/react-dom": "^18.2.19", 25 | "@vitejs/plugin-react": "^4.2.1", 26 | "eslint": "^8.56.0", 27 | "eslint-plugin-react": "^7.33.2", 28 | "eslint-plugin-react-hooks": "^4.6.0", 29 | "eslint-plugin-react-refresh": "^0.4.5", 30 | "vite": "^6.2.7" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /web/public/assets/fonts/inter-v12-latin-300.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/fonts/inter-v12-latin-300.ttf -------------------------------------------------------------------------------- /web/public/assets/fonts/inter-v12-latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/fonts/inter-v12-latin-300.woff2 -------------------------------------------------------------------------------- /web/public/assets/fonts/inter-v12-latin-500.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/fonts/inter-v12-latin-500.ttf -------------------------------------------------------------------------------- /web/public/assets/fonts/inter-v12-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/fonts/inter-v12-latin-500.woff2 -------------------------------------------------------------------------------- /web/public/assets/fonts/inter-v12-latin-700.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/fonts/inter-v12-latin-700.ttf -------------------------------------------------------------------------------- /web/public/assets/fonts/inter-v12-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/fonts/inter-v12-latin-700.woff2 -------------------------------------------------------------------------------- /web/public/assets/fonts/inter-v12-latin-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/fonts/inter-v12-latin-900.ttf -------------------------------------------------------------------------------- /web/public/assets/fonts/inter-v12-latin-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/fonts/inter-v12-latin-900.woff2 -------------------------------------------------------------------------------- /web/public/assets/fonts/inter-v12-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/fonts/inter-v12-latin-regular.ttf -------------------------------------------------------------------------------- /web/public/assets/fonts/inter-v12-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/fonts/inter-v12-latin-regular.woff2 -------------------------------------------------------------------------------- /web/public/assets/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/img/favicon.ico -------------------------------------------------------------------------------- /web/public/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/img/logo.png -------------------------------------------------------------------------------- /web/public/assets/img/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/public/assets/img/logo192.png -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /web/src/common/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/common/assets/background.png -------------------------------------------------------------------------------- /web/src/common/assets/feature/cron.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/common/assets/feature/cron.png -------------------------------------------------------------------------------- /web/src/common/assets/feature/integrations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/common/assets/feature/integrations.png -------------------------------------------------------------------------------- /web/src/common/assets/feature/language.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/common/assets/feature/language.png -------------------------------------------------------------------------------- /web/src/common/assets/feature/views.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/common/assets/feature/views.png -------------------------------------------------------------------------------- /web/src/common/assets/interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/common/assets/interface.png -------------------------------------------------------------------------------- /web/src/common/assets/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/common/assets/logo192.png -------------------------------------------------------------------------------- /web/src/common/assets/logo_docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/common/assets/logo_docs.png -------------------------------------------------------------------------------- /web/src/common/assets/sc1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/common/assets/sc1.png -------------------------------------------------------------------------------- /web/src/common/assets/sc2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/common/assets/sc2.png -------------------------------------------------------------------------------- /web/src/common/components/Button/Button.jsx: -------------------------------------------------------------------------------- 1 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 2 | import "./styles.sass"; 3 | 4 | export const Button = ({color, text, icon, onClick, disabled}) => { 5 | return ( 6 | 10 | ); 11 | } -------------------------------------------------------------------------------- /web/src/common/components/Button/index.js: -------------------------------------------------------------------------------- 1 | export {Button as default} from "./Button.jsx"; -------------------------------------------------------------------------------- /web/src/common/components/Button/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .btn 4 | padding: 0.5rem 1rem 5 | border-radius: 0.8rem 6 | border: 1px solid transparent 7 | display: flex 8 | align-items: center 9 | transition: all 0.2s ease-in-out 10 | cursor: pointer 11 | 12 | .btn:hover 13 | transform: translateY(-2px) 14 | filter: brightness(1.2) 15 | 16 | .btn:active 17 | transform: translateY(0) scale(0.98) 18 | 19 | .btn svg 20 | margin-right: 10px 21 | font-size: 18pt 22 | 23 | .btn h1 24 | margin: 0 25 | font-weight: 900 26 | 27 | .btn-color-red 28 | background-color: rgba($red, 0.1) 29 | color: $red 30 | border: 1px solid $red 31 | 32 | .btn-color-green 33 | background-color: rgba($primary, 0.1) 34 | color: $primary 35 | border: 1px solid $primary 36 | 37 | .btn-color-blue 38 | background-color: rgba($blue, 0.1) 39 | color: $blue 40 | border: 1px solid $blue -------------------------------------------------------------------------------- /web/src/common/components/Navigation/index.js: -------------------------------------------------------------------------------- 1 | export {Navigation as default} from "./Navigation.jsx"; -------------------------------------------------------------------------------- /web/src/common/layouts/Root/Root.jsx: -------------------------------------------------------------------------------- 1 | import {Outlet} from "react-router-dom"; 2 | import Navigation from "@/common/components/Navigation"; 3 | import "./styles.sass"; 4 | import {useEffect} from "react"; 5 | 6 | export const Root = () => { 7 | 8 | useEffect(() => { 9 | const handleKeydown = (event) => { 10 | if (event.ctrlKey && (event.key === "+" || event.key === "-")) event.preventDefault(); 11 | } 12 | 13 | const handleWheel = (event) => { 14 | if (event.ctrlKey) event.preventDefault(); 15 | } 16 | 17 | document.addEventListener("keydown", handleKeydown); 18 | document.addEventListener("wheel", handleWheel, {passive: false}); 19 | 20 | return () => { 21 | document.removeEventListener("keydown", handleKeydown); 22 | document.removeEventListener("wheel", handleWheel); 23 | } 24 | }, []); 25 | 26 | return ( 27 | <> 28 | 29 |
30 | 31 |
32 | 33 | ); 34 | } -------------------------------------------------------------------------------- /web/src/common/layouts/Root/index.js: -------------------------------------------------------------------------------- 1 | export {Root as default} from "./Root.jsx"; -------------------------------------------------------------------------------- /web/src/common/layouts/Root/styles.sass: -------------------------------------------------------------------------------- 1 | main 2 | padding: 1rem 3 | height: calc(100vh - 7rem) 4 | 5 | @media screen and (max-width: 1000px) 6 | main 7 | padding: 0.2rem -------------------------------------------------------------------------------- /web/src/common/styles/_colors.sass: -------------------------------------------------------------------------------- 1 | $background: #131A20 2 | $background-lighter: #1E222F 3 | $text: #E5E5E5 4 | $primary: #45C65A 5 | $blue: #456AC6 6 | $red: #EC5555 -------------------------------------------------------------------------------- /web/src/common/styles/default.sass: -------------------------------------------------------------------------------- 1 | @import "colors" 2 | 3 | body, html 4 | margin: 0 5 | background-color: $background 6 | color: $text 7 | font-family: "Inter", sans-serif 8 | font-weight: 700 9 | 10 | ::-webkit-scrollbar 11 | width: 13px 12 | 13 | ::-webkit-scrollbar-thumb 14 | filter: brightness(0.7) 15 | border-radius: 10px 16 | 17 | ::-webkit-scrollbar-thumb:hover 18 | filter: brightness(0.8) -------------------------------------------------------------------------------- /web/src/common/styles/fonts.sass: -------------------------------------------------------------------------------- 1 | @font-face 2 | font-display: swap 3 | font-family: 'Inter' 4 | font-style: normal 5 | font-weight: 300 6 | src: url('/assets/fonts/inter-v12-latin-300.woff2') format("woff2"), url('/assets/fonts/inter-v12-latin-300.ttf') format("truetype") 7 | 8 | @font-face 9 | font-display: swap 10 | font-family: 'Inter' 11 | font-style: normal 12 | font-weight: 400 13 | src: url('/assets/fonts/inter-v12-latin-regular.woff2') format("woff2"), url('/assets/fonts/inter-v12-latin-regular.ttf') format("truetype") 14 | 15 | @font-face 16 | font-display: swap 17 | font-family: 'Inter' 18 | font-style: normal 19 | font-weight: 500 20 | src: url('/assets/fonts/inter-v12-latin-500.woff2') format("woff2"), url('/assets/fonts/inter-v12-latin-500.ttf') format("truetype") 21 | 22 | @font-face 23 | font-display: swap 24 | font-family: 'Inter' 25 | font-style: normal 26 | font-weight: 700 27 | src: url('/assets/fonts/inter-v12-latin-700.woff2') format("woff2"), url('/assets/fonts/inter-v12-latin-700.ttf') format("truetype") 28 | 29 | @font-face 30 | font-display: swap 31 | font-family: 'Inter' 32 | font-style: normal 33 | font-weight: 900 34 | src: url('/assets/fonts/inter-v12-latin-900.woff2') format("woff2"), url('/assets/fonts/inter-v12-latin-900.ttf') format("truetype") -------------------------------------------------------------------------------- /web/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "@/common/styles/fonts.sass"; 4 | import "@/common/styles/default.sass"; 5 | import {createBrowserRouter, RouterProvider} from "react-router-dom"; 6 | import Root from "@/common/layouts/Root"; 7 | import NotFound from "@/pages/NotFound"; 8 | import Home from "@/pages/Home"; 9 | import Imprint from "@/pages/Imprint"; 10 | import Privacy from "@/pages/Privacy"; 11 | import Install from "@/pages/Install"; 12 | import Tutorials from "@/pages/Tutorials"; 13 | import TutorialSubmission from "@/pages/TutorialSubmission"; 14 | 15 | const router = createBrowserRouter([ 16 | { 17 | path: "/", 18 | element: , 19 | errorElement: , 20 | children: [ 21 | {path: "/", element: }, 22 | {path: "/install", element: }, 23 | {path: "/tutorials", element: }, 24 | {path: "/tutorials/submit", element: }, 25 | {path: "/imprint", element: }, 26 | {path: "/privacy", element: }, 27 | ] 28 | } 29 | ]); 30 | 31 | export const DOCUMENTATION_BASE = "https://docs.myspeed.dev"; 32 | 33 | ReactDOM.createRoot(document.getElementById("root")).render( 34 | 35 | 36 | , 37 | ); -------------------------------------------------------------------------------- /web/src/pages/Home/Home.jsx: -------------------------------------------------------------------------------- 1 | import InterfaceImage from "@/common/assets/interface.png"; 2 | import Logo from "@/common/assets/logo192.png"; 3 | import "./styles.sass"; 4 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 5 | import {faArrowDown, faArrowUp} from "@fortawesome/free-solid-svg-icons"; 6 | import Features from "@/pages/Home/components/Features"; 7 | import FeatureGrid from "@/pages/Home/components/FeatureGrid"; 8 | import GetStarted from "@/pages/Home/components/GetStarted"; 9 | import Footer from "@/pages/Home/components/Footer"; 10 | 11 | 12 | const COLORS = ["#45C65A", "#E58A00", "#EC5555"]; 13 | 14 | const chooseRandomColor = () => COLORS[Math.floor(Math.random() * COLORS.length)]; 15 | 16 | export const Home = () => { 17 | return ( 18 |
19 | 20 |
21 | Logo 22 |
23 |

MySpeed

24 |

Speedtest automation made simple

25 |
26 |
27 | 28 | {Array(5).fill(0).map((_, index) => ( 29 |
30 | 31 | 32 |
33 | ))} 34 | 35 |
36 | Interface 37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
46 |
47 | ) 48 | } -------------------------------------------------------------------------------- /web/src/pages/Home/components/FeatureGrid/FeatureGrid.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | 3 | import LanguageFeature from "@/common/assets/feature/language.png"; 4 | import ViewsFeature from "@/common/assets/feature/views.png"; 5 | import IntegrationsFeature from "@/common/assets/feature/integrations.png"; 6 | import CronFeature from "@/common/assets/feature/cron.png"; 7 | 8 | export const TRANSLATIONS_LINK = "https://crowdin.com/project/myspeed"; 9 | 10 | export const FeatureGrid = () => { 11 | return ( 12 |
13 |
14 | 19 |
20 | Multiple Views 21 |

Multiple Views

22 |

Choose between different views to display your data.

23 |
24 |
25 |
26 |
27 | Extensible 28 |

Extensible

29 |

You can integrate MySpeed in WhatsApp, Discord, HealthChecks or your own Webhook

30 |
31 |
32 | Cron Jobs 33 |

Choose your time

34 |

Whether you want to check every 5 minutes or every 5 hours, MySpeed has you covered.

35 |
36 |
37 |
38 | ) 39 | } -------------------------------------------------------------------------------- /web/src/pages/Home/components/FeatureGrid/index.js: -------------------------------------------------------------------------------- 1 | export {FeatureGrid as default} from "./FeatureGrid.jsx"; -------------------------------------------------------------------------------- /web/src/pages/Home/components/FeatureGrid/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .feature-grid-section 4 | margin-top: 5rem 5 | 6 | .feature-grid-row 7 | display: flex 8 | align-items: center 9 | justify-content: center 10 | 11 | .feature-box 12 | display: flex 13 | flex-direction: column 14 | align-items: center 15 | width: 35rem 16 | 17 | img 18 | width: 20rem 19 | 20 | h1 21 | margin-top: 2rem 22 | font-size: 2rem 23 | 24 | p 25 | margin-top: 1rem 26 | font-size: 1.3rem 27 | width: 25rem 28 | text-align: center 29 | 30 | a 31 | color: $primary 32 | 33 | @media screen and (max-width: 800px) 34 | .feature-grid-row 35 | flex-direction: column 36 | gap: 2rem -------------------------------------------------------------------------------- /web/src/pages/Home/components/Features/Features.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | import Screenshot1 from "@/common/assets/sc1.png"; 3 | import Screenshot2 from "@/common/assets/sc2.png"; 4 | 5 | 6 | export const Features = () => { 7 | return ( 8 |
9 |
10 | Screenshot - What is MySpeed? 11 |
12 |

What is MySpeed?

13 |

MySpeed is a Software that helps you keeping track of your network speed.

It 14 | automatically creates speedtests based on your schedule and displays them in a list.

15 |
16 |
17 | 18 |
19 | Screenshot - Detailed statistics 20 |
21 |

Detailed statistics

22 |

MySpeed provides you with detailed statistics about your network speed.

This 23 | includes data about your download and upload speed, as well as your ping.

24 |
25 |
26 | 27 |
28 | ) 29 | } -------------------------------------------------------------------------------- /web/src/pages/Home/components/Features/index.js: -------------------------------------------------------------------------------- 1 | export {Features as default} from "./Features.jsx"; -------------------------------------------------------------------------------- /web/src/pages/Home/components/Features/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .feature-area 4 | margin-top: 10vh 5 | padding-left: 5rem 6 | padding-right: 5rem 7 | display: flex 8 | flex-direction: column 9 | gap: 3rem 10 | 11 | .feature 12 | display: flex 13 | gap: 2rem 14 | justify-content: space-around 15 | align-items: center 16 | 17 | img 18 | width: 40rem 19 | border-radius: 1rem 20 | transition: 0.3s 21 | 22 | img:hover 23 | transform: scale(1.02) translateY(-0.5rem) 24 | box-shadow: 0 0 1rem 0.5rem $background-lighter 25 | 26 | h1 27 | font-size: 3rem 28 | margin-bottom: 1rem 29 | 30 | p 31 | font-size: 1.6rem 32 | margin-bottom: 2rem 33 | max-width: 40rem 34 | 35 | .feature-reverse 36 | flex-direction: row-reverse 37 | 38 | @media screen and (max-width: 1200px) 39 | .feature-area .feature img 40 | width: 30rem 41 | .feature-area .feature h1 42 | font-size: 2.5rem 43 | .feature-area .feature p 44 | font-size: 1.3rem 45 | max-width: 30rem 46 | 47 | @media screen and (max-width: 1000px) 48 | .feature-area .feature img 49 | width: 25rem 50 | .feature-area .feature h1 51 | font-size: 2rem 52 | .feature-area .feature p 53 | font-size: 1rem 54 | max-width: 30rem 55 | 56 | @media screen and (max-width: 700px) 57 | .feature-area .feature 58 | width: 100% 59 | .feature-area .feature img 60 | width: 70vw 61 | .feature-area .feature 62 | flex-direction: column 63 | .feature-area .feature p 64 | margin-bottom: 1rem 65 | max-width: 100% -------------------------------------------------------------------------------- /web/src/pages/Home/components/Footer/Footer.jsx: -------------------------------------------------------------------------------- 1 | import {Link} from "react-router-dom"; 2 | import "./styles.sass"; 3 | 4 | export const Footer = () => { 5 | return ( 6 |
7 |

© {new Date().getFullYear()} Mathias Wagner

8 | 9 |
10 | Imprint 11 | Privacy Policy (GER) 12 |
13 |
14 | ) 15 | } -------------------------------------------------------------------------------- /web/src/pages/Home/components/Footer/index.js: -------------------------------------------------------------------------------- 1 | export {Footer as default} from "./Footer.jsx"; -------------------------------------------------------------------------------- /web/src/pages/Home/components/Footer/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .footer 4 | background-color: $background-lighter 5 | margin: 0 5rem 0 5rem 6 | padding: 0 5rem 7 | display: flex 8 | align-items: center 9 | justify-content: space-between 10 | 11 | .legal-nav 12 | display: flex 13 | gap: 1rem 14 | 15 | a 16 | color: $text 17 | text-decoration: none 18 | 19 | a:hover 20 | text-decoration: underline 21 | 22 | 23 | @media screen and (max-width: 710px) 24 | .footer 25 | margin: 0 0 0 1rem 26 | padding: 0 2rem 27 | flex-direction: column -------------------------------------------------------------------------------- /web/src/pages/Home/components/GetStarted/GetStarted.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | 3 | import DocsLogo from "@/common/assets/logo_docs.png"; 4 | import Button from "@/common/components/Button/index.js"; 5 | import {faPlay} from "@fortawesome/free-solid-svg-icons"; 6 | import {Link, useNavigate} from "react-router-dom"; 7 | import {DOCUMENTATION_BASE} from "@/main.jsx"; 8 | 9 | export const GetStarted = () => { 10 | const navigate = useNavigate(); 11 | return ( 12 |
13 |
14 |

Convinced?

15 |

Great! Press the button below to get started.

16 |
19 | 20 | Docs Logo 21 |
22 | ); 23 | } -------------------------------------------------------------------------------- /web/src/pages/Home/components/GetStarted/index.js: -------------------------------------------------------------------------------- 1 | export {GetStarted as default} from "./GetStarted.jsx"; -------------------------------------------------------------------------------- /web/src/pages/Home/components/GetStarted/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .get-started 4 | display: flex 5 | align-items: center 6 | justify-content: space-between 7 | background-color: $background-lighter 8 | border-top-left-radius: 5rem 9 | border-top-right-radius: 5rem 10 | padding: 2rem 5rem 1rem 5rem 11 | margin: 2rem 5rem 0 5rem 12 | 13 | 14 | h1 15 | font-size: 2.4rem 16 | margin: 0 17 | 18 | h2 19 | font-size: 1.5rem 20 | margin: 1rem 0 21 | 22 | .btn 23 | h1 24 | font-size: 1.3rem 25 | 26 | img 27 | width: 5rem 28 | 29 | @media screen and (max-width: 710px) 30 | .get-started 31 | padding: 2rem 2rem 1rem 2rem 32 | margin: 2rem 0 0 1rem 33 | 34 | h1 35 | font-size: 2rem 36 | 37 | h2 38 | font-size: 1.3rem 39 | 40 | h1 41 | font-size: 1.1rem 42 | 43 | img 44 | display: none -------------------------------------------------------------------------------- /web/src/pages/Home/index.js: -------------------------------------------------------------------------------- 1 | export {Home as default} from "./Home.jsx"; -------------------------------------------------------------------------------- /web/src/pages/Imprint/Imprint.jsx: -------------------------------------------------------------------------------- 1 | import "./styles.sass"; 2 | 3 | export const Imprint = () => { 4 | return ( 5 |
6 |

Imprint

7 |

Information in accordance with § 5 TMG

8 | 9 |

Mathias Wagner
c/o IP-Management #14358
Ludwig-Erhard-Str. 18
20459 Hamburg

10 | 11 |

Contact

12 |

E-Mail: mathias@gnmyt.dev

13 |
14 | ) 15 | } -------------------------------------------------------------------------------- /web/src/pages/Imprint/index.js: -------------------------------------------------------------------------------- 1 | export {Imprint as default} from "./Imprint.jsx"; -------------------------------------------------------------------------------- /web/src/pages/Imprint/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .imprint-page 4 | padding: 5rem 5 | 6 | h1 7 | font-size: 3rem 8 | margin: 0 0 2rem 9 | color: $primary 10 | 11 | h2 12 | font-size: 1.6rem 13 | margin-bottom: 1rem 14 | color: $primary 15 | filter: brightness(0.9) -------------------------------------------------------------------------------- /web/src/pages/Install/index.js: -------------------------------------------------------------------------------- 1 | export {Install as default} from "./Install.jsx"; -------------------------------------------------------------------------------- /web/src/pages/NotFound/NotFound.jsx: -------------------------------------------------------------------------------- 1 | import {useEffect} from "react"; 2 | import "./styles.sass"; 3 | import NotFoundImage from "@/common/assets/not_found.svg"; 4 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 5 | import {faQuestionCircle} from "@fortawesome/free-solid-svg-icons"; 6 | 7 | export const NotFound = () => { 8 | 9 | useEffect(() => { 10 | const timeout = setTimeout(() => { 11 | window.location.href = "/"; 12 | }, 2000); 13 | 14 | return () => clearTimeout(timeout); 15 | }, []); 16 | 17 | return ( 18 |
19 |
20 |
21 | 22 |

Page not found

23 |
24 |

You will be redirected to the home page in a few seconds.

25 |
26 | Interface 27 |
28 | ); 29 | } -------------------------------------------------------------------------------- /web/src/pages/NotFound/index.js: -------------------------------------------------------------------------------- 1 | export {NotFound as default} from "./NotFound.jsx"; -------------------------------------------------------------------------------- /web/src/pages/NotFound/styles.sass: -------------------------------------------------------------------------------- 1 | .page-404 2 | display: flex 3 | height: 100vh 4 | justify-content: space-around 5 | align-items: center 6 | flex-wrap: wrap 7 | 8 | .logo-container 9 | display: flex 10 | align-items: center 11 | 12 | h1 13 | margin: 1rem 14 | 15 | img 16 | width: 30rem 17 | 18 | @media screen and (max-width: 768px) 19 | .page-404 20 | padding: 2rem 21 | gap: 2rem 22 | 23 | .page-404 img 24 | width: 60vw -------------------------------------------------------------------------------- /web/src/pages/Privacy/index.js: -------------------------------------------------------------------------------- 1 | export {Privacy as default} from "./Privacy.jsx"; -------------------------------------------------------------------------------- /web/src/pages/Privacy/styles.sass: -------------------------------------------------------------------------------- 1 | @import "@/common/styles/colors" 2 | 3 | .privacy-page 4 | padding: 5rem 5 | 6 | h1 7 | font-size: 3rem 8 | margin: 0 0 2rem 9 | color: $primary 10 | 11 | h2 12 | font-size: 1.6rem 13 | margin-bottom: 1rem 14 | color: $primary 15 | filter: brightness(0.9) 16 | 17 | h3 18 | font-size: 1.3rem 19 | margin-bottom: 1rem 20 | color: $primary 21 | filter: brightness(0.8) 22 | 23 | h4 24 | font-size: 1.1rem 25 | margin-bottom: 1rem 26 | 27 | a 28 | color: $primary 29 | text-decoration: underline 30 | transition: color 0.2s 31 | 32 | &:hover 33 | color: darken($primary, 10%) 34 | 35 | @media screen and (max-width: 1000px) 36 | .privacy-page 37 | padding: 5rem 1rem 38 | 39 | h1 40 | font-size: 2rem 41 | h2 42 | font-size: 1.4rem 43 | h3 44 | font-size: 1.1rem 45 | h4 46 | font-size: 1rem -------------------------------------------------------------------------------- /web/src/pages/TutorialSubmission/index.js: -------------------------------------------------------------------------------- 1 | export {TutorialSubmission as default} from "./TutorialSubmission.jsx"; -------------------------------------------------------------------------------- /web/src/pages/Tutorials/index.js: -------------------------------------------------------------------------------- 1 | export {Tutorials as default} from "./Tutorials.jsx"; -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/channels/addrom.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/channels/addrom.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/channels/belginux.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/channels/belginux.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/channels/bigbeartechworld.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/channels/bigbeartechworld.png -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/channels/dbtech.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/channels/dbtech.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/channels/gigazine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/channels/gigazine.png -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/channels/linuxiac.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/channels/linuxiac.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/channels/mariushosting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/channels/mariushosting.png -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/channels/pavl21.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/channels/pavl21.png -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/channels/retromiketech.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/channels/retromiketech.png -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/channels/ubunlog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/channels/ubunlog.png -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/20240128.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/20240128.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/7108075382452079878.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/7108075382452079878.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/7roj87Fytz0.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/7roj87Fytz0.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/Iic14oUCCVo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/Iic14oUCCVo.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/MFbeWdKesTE.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/MFbeWdKesTE.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/SM3RJRktwIk.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/SM3RJRktwIk.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/ZIIa6yF-Tvo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/ZIIa6yF-Tvo.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/addrom-myspeed.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/addrom-myspeed.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/belginux-myspeed.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/belginux-myspeed.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/linuxiac-myspeed.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/linuxiac-myspeed.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/marius-myspeed.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/marius-myspeed.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/tBJmhgn3ZOM.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/tBJmhgn3ZOM.webp -------------------------------------------------------------------------------- /web/src/pages/Tutorials/sources/thumbs/ubunlog-myspeed.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnmyt/MySpeed/5b41668ee5ecb3bde8898e32e5dacba0e523ef03/web/src/pages/Tutorials/sources/thumbs/ubunlog-myspeed.webp -------------------------------------------------------------------------------- /web/vite.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "vite" 2 | import react from "@vitejs/plugin-react"; 3 | import path from "path"; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | "@": path.resolve(__dirname, "./src"), 10 | }, 11 | } 12 | }); --------------------------------------------------------------------------------