Merge pull request #1343 from gnmyt/updates/redesign

🎨 Redesign
This commit is contained in:
Mathias Wagner
2026-01-20 00:17:02 +01:00
committed by GitHub
134 changed files with 5517 additions and 2418 deletions
+62 -7
View File
@@ -52,6 +52,7 @@
"upload": "Optimal up-speed",
"download": "Optimal down-speed",
"recommendations": "Recommendations",
"optimal_values": "Optimal values",
"change_provider": "Change provider",
"password": "Change password",
"cron": "Set frequency",
@@ -64,10 +65,18 @@
"dark_mode": "Dark mode",
"theme_switched_light": "Switched to light mode",
"theme_switched_dark": "Switched to dark mode",
"info": "About the project",
"provider": "About the provider",
"integrations": "Integrations"
},
"about": {
"title": "About MySpeed",
"description": "MySpeed is an open-source speedtest analysis software that records your internet speed for up to 30 days.",
"github": "GitHub",
"website": "Website",
"donate": "Donate",
"translate": "Translate",
"made_by": "Made with"
},
"options": {
"cron": {
"continuous": "Continuous (every minute)",
@@ -122,6 +131,7 @@
"paused": "Speedtests are currently paused. Please continue them if you want to do one.",
"running": "A speedtest is already running. Please wait a moment.",
"admin_login": "Admin Login",
"servers": "Servers",
"beta": {
"title": "Beta Version",
"description": "This feature is still in beta. If you find any bugs, please report them <Link>here</Link>."
@@ -165,7 +175,6 @@
"before": "before"
},
"info": {
"credits": "<MSpeed>MySpeed</MSpeed> is a open source project provided by GNMYT. Leave a star on <Github>GitHub</Github> or <Donate>donate</Donate> to support the project.",
"recommendations_error": "You have to do at least 10 tests to get an average. It doesn't matter if the tests were done manually or automatically.",
"recommendations_info": "Based on the last 10 tests, it was found that the optimal ping was <Bold>{{ping}} ms</Bold>, the download at <Bold>{{down}} Mbps</Bold> and the upload at <Bold>{{up}} Mbps</Bold>. It is best to orientate yourself on your internet contract and only adopt it if it matches that.",
"update": "An update to version {{version}} is available. See <Changes>the changes</Changes> and <DLLink>download the update</DLLink>.",
@@ -217,11 +226,7 @@
"automatic": "automatically"
},
"overview": {
"title": "Test overview of the last {{amount}}",
"1": "24 hours",
"2": "2 days",
"3": "7 days",
"4": "30 days"
"title_range": "Test overview from {{from}} to {{to}}"
}
},
"errors": {
@@ -236,6 +241,7 @@
"invalid_view": "Invalid view"
},
"statistics": {
"average": "Average",
"overview": {
"total_title": "Total tests",
"total_description": "The number of executed tests",
@@ -268,8 +274,42 @@
"avg": "Average",
"down": "Download values",
"up": "Upload values"
},
"hourly": {
"title": "Speed by Time of Day",
"sample_count": "Tests"
},
"consistency": {
"title": "Connection Stability",
"excellent": "Excellent",
"good": "Good",
"fair": "Fair",
"poor": "Poor",
"ping_variance": "Ping variance"
},
"aggregation": {
"daily": "Showing daily averages",
"weekly": "Showing weekly averages"
}
},
"calendar": {
"select_range": "Select date range",
"select_start": "Select start date",
"select_end": "Select end date",
"from": "From",
"to": "To",
"last_7_days": "Last 7 days",
"last_30_days": "Last 30 days",
"last_90_days": "Last 90 days",
"last_year": "Last year",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun"
},
"nodes": {
"add": "Add server",
"create": "Add",
@@ -392,5 +432,20 @@
},
"common": {
"back_to_top": "Back to top"
},
"not_found": {
"title": "Page not found",
"description": "The page you're looking for doesn't exist or has been moved.",
"back_home": "Back to Home"
},
"route_error": {
"title": "Oops!",
"subtitle": "Something went wrong",
"description": "An unexpected error occurred. Please try again or go back to the home page.",
"reload": "Reload Page"
},
"optimal_values": {
"title": "Optimal Values",
"use_recommended": "Use recommended"
}
}
+2
View File
@@ -16,6 +16,7 @@ import {ThemeProvider} from "@/common/contexts/Theme";
import i18n from './i18n';
import Loading from "@/pages/Loading";
import Error from "@/pages/Error";
import RouteError from "@/pages/RouteError";
import {ToastNotificationProvider} from "@/common/contexts/ToastNotification";
import {NodeProvider} from "@/common/contexts/Node";
import {library} from '@fortawesome/fontawesome-svg-core';
@@ -65,6 +66,7 @@ const App = () => {
<main><Outlet/></main>
</Providers>
),
errorElement: <RouteError />,
children: [
{path: "/", element: <Home/>},
{path: "/nodes", element: <Nodes/>},
@@ -0,0 +1,77 @@
import {DialogContext, DialogProvider} from "@/common/contexts/Dialog";
import "./styles.sass";
import React, {useContext, useEffect, useState} from "react";
import {t} from "i18next";
import {Trans} from "react-i18next";
import {faClose, faGlobe, faHeart, faLanguage} from "@fortawesome/free-solid-svg-icons";
import {faGithub} from "@fortawesome/free-brands-svg-icons";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {jsonRequest} from "@/common/utils/RequestUtil";
import {PROJECT_URL, WEB_URL, DONATION_URL} from "@/index";
const Dialog = ({version}) => {
const close = useContext(DialogContext);
const links = [
{icon: faGithub, label: t("about.github"), url: PROJECT_URL},
{icon: faGlobe, label: t("about.website"), url: WEB_URL},
{icon: faHeart, label: t("about.donate"), url: DONATION_URL, accent: true},
{icon: faLanguage, label: t("about.translate"), url: "https://crowdin.com/project/myspeed"}
];
return (
<>
<div className="dialog-header">
<h4 className="dialog-text">{t("about.title")}</h4>
<FontAwesomeIcon icon={faClose} className="dialog-text dialog-icon" onClick={() => close()}/>
</div>
<div className="about-wrapper">
<div className="about-hero">
<img src="/assets/img/logo192.png" alt="MySpeed Logo" className="about-logo"/>
<div className="about-title-section">
<h1 className="about-title">MySpeed</h1>
<span className="about-version">v{version}</span>
</div>
</div>
<p className="about-description">
<Trans i18nKey="about.description">
MySpeed is an open-source speedtest analysis software that records your internet speed for up to 30 days.
</Trans>
</p>
<div className="about-links">
{links.map((link, index) => (
<a key={index} href={link.url} target="_blank" rel="noopener noreferrer"
className={"about-link" + (link.accent ? " about-link-accent" : "")}>
<FontAwesomeIcon icon={link.icon}/>
<span>{link.label}</span>
</a>
))}
</div>
<p className="about-footer">
{t("about.made_by")} <FontAwesomeIcon icon={faHeart} className="about-heart"/> by <a href="https://github.com/gnmyt" target="_blank" rel="noopener noreferrer">GNMYT</a>
</p>
</div>
</>
);
};
export const AboutDialog = (props) => {
const [version, setVersion] = useState(null);
useEffect(() => {
jsonRequest("/info/version").then(data => setVersion(data.local));
}, []);
return (
<DialogProvider close={props.onClose} customClass={!version ? "dialog-loading" : "about-dialog"}>
{!version && (
<div className="lds-ellipsis"><div/><div/><div/><div/></div>
)}
{version && <Dialog version={version}/>}
</DialogProvider>
);
};
@@ -0,0 +1 @@
export {AboutDialog as default} from "./AboutDialog.jsx";
@@ -0,0 +1,104 @@
@use "@/common/styles/colors" as *
.about-dialog
max-width: 26rem
.about-wrapper
display: flex
flex-direction: column
gap: 1rem
margin-top: 1rem
.about-hero
display: flex
align-items: center
gap: 1rem
.about-logo
width: 56px
height: 56px
.about-title-section
display: flex
flex-direction: column
gap: 0.15rem
.about-title
margin: 0
font-size: 1.5rem
font-weight: 700
color: $white
.about-version
font-size: 0.85rem
color: $accent-primary
font-weight: 600
.about-description
margin: 0
font-size: 0.9rem
color: $subtext
line-height: 1.5
.about-links
display: grid
grid-template-columns: 1fr 1fr
gap: 0.5rem
.about-link
display: flex
align-items: center
gap: 0.6rem
padding: 0.6rem 0.85rem
border: 1px solid $light-gray
border-radius: 0.5rem
color: $white
text-decoration: none
font-size: 0.85rem
font-weight: 500
transition: all 0.2s ease
svg
color: $subtext
font-size: 1rem
&:hover
background: $darker-gray
svg
color: $white
.about-link-accent
svg
color: $accent-danger
&:hover
svg
color: $accent-danger
.about-footer
margin: 0
padding-top: 0.5rem
font-size: 0.8rem
color: $subtext
text-align: center
border-top: 1px solid $light-gray
a
color: $accent-primary
text-decoration: none
&:hover
text-decoration: underline
.about-heart
color: $accent-danger
font-size: 0.7rem
margin: 0 0.1rem
@media (max-width: 500px)
.about-dialog
max-width: 90vw
.about-links
grid-template-columns: 1fr
@@ -0,0 +1,44 @@
import { useEffect, useCallback } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import "./styles.sass";
export const ChartModal = ({ isOpen, onClose, isChart = false, children }) => {
const handleEscape = useCallback((e) => {
if (e.key === "Escape") {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "";
};
}, [isOpen, handleEscape]);
if (!isOpen) return null;
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div className="chart-modal-backdrop" onClick={handleBackdropClick}>
<div className={`chart-modal-content${isChart ? ' modal-chart' : ''}`}>
<button className="chart-modal-close" onClick={onClose}>
<FontAwesomeIcon icon={faXmark} />
</button>
<div className={`chart-modal-body${isChart ? ' modal-body-chart' : ''}`}>
{children}
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { ChartModal as default } from "./ChartModal";
@@ -0,0 +1,156 @@
@use "@/common/styles/colors" as *
.chart-modal-backdrop
position: fixed
top: 0
left: 0
right: 0
bottom: 0
background-color: rgba(0, 0, 0, 0.85)
display: flex
align-items: center
justify-content: center
z-index: 1000
padding: 2rem
animation: modalFadeIn 0.25s ease-out
backdrop-filter: blur(4px)
.chart-modal-content
position: relative
width: fit-content
min-width: 400px
max-width: 1400px
max-height: 90vh
background-color: $dark-gray
backdrop-filter: blur(4px)
-webkit-backdrop-filter: blur(4px)
border: 1px solid $light-gray
border-radius: 1rem
overflow: hidden
animation: scaleIn 0.25s ease-out
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4)
&.modal-chart
width: 100%
.chart-modal-close
position: absolute
top: 1rem
right: 1rem
background: $darker-gray
border: 1px solid $light-gray
border-radius: 0.5rem
color: $subtext
width: 2.5rem
height: 2.5rem
display: flex
align-items: center
justify-content: center
cursor: pointer
transition: all 0.2s ease
z-index: 10
font-size: 1.25rem
&:hover
background: $light-gray
color: $white
border-color: $accent-primary
.chart-modal-body
padding: 1.5rem
padding-top: 3.5rem
.chart-container,
.stats-container
width: 100%
border: none
transform: none !important
cursor: default
box-shadow: none
&:hover
border: none
transform: none !important
box-shadow: none
&.modal-body-chart
.chart-container
min-height: 70vh
.chart-body
min-height: 60vh
.stats-content
height: auto
min-height: auto
.consistency-container
gap: 2rem
.consistency-item
svg
width: 3rem
height: 3rem
.consistency-info
h2
font-size: 1.25rem
p
font-size: 1.25rem
.consistency-detail
font-size: 0.95rem
.overview-items
gap: 1.5rem
.overview-item
.info-area
svg
width: 3rem
height: 3rem
.text-area
h2
font-size: 1.25rem
p
font-size: 0.9rem
h2
font-size: 1.75rem
.info-container
gap: 2rem
.test-container
svg
width: 3rem
height: 3rem
.test-info
h2
font-size: 1.25rem
p
font-size: 1.1rem
.value-container
gap: 2rem
.value-item
svg
width: 3rem
height: 3rem
.value-info
h2
font-size: 1.25rem
p
font-size: 1.1rem
@keyframes modalFadeIn
from
opacity: 0
to
opacity: 1
@keyframes scaleIn
from
opacity: 0
transform: scale(0.96)
to
opacity: 1
transform: scale(1)
@@ -0,0 +1,249 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCalendar, faChevronLeft, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { t } from "i18next";
import "./styles.sass";
export const DateRangePicker = ({ from, to, onChange, minDate, maxDate }) => {
const [isOpen, setIsOpen] = useState(false);
const [selecting, setSelecting] = useState("from");
const [tempFrom, setTempFrom] = useState(from);
const [tempTo, setTempTo] = useState(to);
const [hoverDate, setHoverDate] = useState(null);
const [currentMonth, setCurrentMonth] = useState(new Date(to || new Date()));
const popoverRef = useRef(null);
const triggerRef = useRef(null);
const today = new Date();
today.setHours(23, 59, 59, 999);
const effectiveMaxDate = maxDate || today;
const todayDateString = new Date().toDateString();
useEffect(() => {
const handleClickOutside = (event) => {
if (popoverRef.current && !popoverRef.current.contains(event.target) &&
triggerRef.current && !triggerRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
setTempFrom(from);
setTempTo(to);
}, [from, to]);
const formatDisplayDate = (date) => {
if (!date) return "";
return date.toLocaleDateString(undefined, {
day: "2-digit",
month: "short",
year: "numeric"
});
};
const getDaysInMonth = (year, month) => {
return new Date(year, month + 1, 0).getDate();
};
const getFirstDayOfMonth = (year, month) => {
const day = new Date(year, month, 1).getDay();
return day === 0 ? 6 : day - 1;
};
const calendarDays = useMemo(() => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const days = [];
const prevMonth = month === 0 ? 11 : month - 1;
const prevYear = month === 0 ? year - 1 : year;
const daysInPrevMonth = getDaysInMonth(prevYear, prevMonth);
for (let i = firstDay - 1; i >= 0; i--) {
days.push({
day: daysInPrevMonth - i,
date: new Date(prevYear, prevMonth, daysInPrevMonth - i),
isCurrentMonth: false
});
}
for (let i = 1; i <= daysInMonth; i++) {
days.push({
day: i,
date: new Date(year, month, i),
isCurrentMonth: true
});
}
const nextMonth = month === 11 ? 0 : month + 1;
const nextYear = month === 11 ? year + 1 : year;
const remainingDays = 42 - days.length;
for (let i = 1; i <= remainingDays; i++) {
days.push({
day: i,
date: new Date(nextYear, nextMonth, i),
isCurrentMonth: false
});
}
return days;
}, [currentMonth]);
const handleDayClick = (date) => {
if (selecting === "from") {
setTempFrom(date);
setSelecting("to");
if (tempTo && date > tempTo) {
setTempTo(null);
}
} else {
if (tempFrom && date < tempFrom) {
setTempTo(tempFrom);
setTempFrom(date);
} else {
setTempTo(date);
}
const finalFrom = tempFrom && date < tempFrom ? date : tempFrom;
const finalTo = tempFrom && date < tempFrom ? tempFrom : date;
onChange(finalFrom, finalTo);
setSelecting("from");
setIsOpen(false);
}
};
const isInRange = (date) => {
if (!tempFrom) return false;
if (selecting === "to" && tempFrom && hoverDate) {
const endDate = hoverDate;
if (endDate < tempFrom) {
return date >= endDate && date <= tempFrom;
}
return date >= tempFrom && date <= endDate;
}
return tempFrom && tempTo && date >= tempFrom && date <= tempTo;
};
const isRangeStart = (date) => {
if (selecting === "to" && tempFrom && hoverDate && hoverDate < tempFrom) {
return date.toDateString() === hoverDate.toDateString();
}
return tempFrom && date.toDateString() === tempFrom.toDateString();
};
const isRangeEnd = (date) => {
if (selecting === "to" && tempFrom && hoverDate) {
if (hoverDate < tempFrom) {
return date.toDateString() === tempFrom.toDateString();
}
return date.toDateString() === hoverDate.toDateString();
}
return tempTo && date.toDateString() === tempTo.toDateString();
};
const isToday = (date) => {
return date.toDateString() === todayDateString;
};
const isSelected = (date) => {
return isRangeStart(date) || isRangeEnd(date);
};
const isDisabled = (date) => {
if (minDate && date < minDate) return true;
if (effectiveMaxDate && date > effectiveMaxDate) return true;
return false;
};
const prevMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
};
const nextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
};
const isCurrentMonthView = () => {
const now = new Date();
return currentMonth.getMonth() === now.getMonth() &&
currentMonth.getFullYear() === now.getFullYear();
};
const weekDays = [
t("calendar.mon"),
t("calendar.tue"),
t("calendar.wed"),
t("calendar.thu"),
t("calendar.fri"),
t("calendar.sat"),
t("calendar.sun")
];
return (
<div className="date-range-picker">
<div
className="date-range-trigger"
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
>
<FontAwesomeIcon icon={faCalendar} className="calendar-icon" />
<span className="date-range-text">
{from && to ? (
<>{formatDisplayDate(from)} - {formatDisplayDate(to)}</>
) : (
t("calendar.select_range")
)}
</span>
</div>
{isOpen && (
<div className="date-range-popover" ref={popoverRef}>
<div className="calendar-nav">
<button className="nav-btn" onClick={prevMonth}>
<FontAwesomeIcon icon={faChevronLeft} />
</button>
<span className="current-month">
{currentMonth.toLocaleDateString(undefined, { month: "long", year: "numeric" })}
</span>
<button
className="nav-btn"
onClick={nextMonth}
disabled={isCurrentMonthView()}
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
</div>
<div className="calendar-grid">
<div className="weekdays">
{weekDays.map((day) => (
<div key={day} className="weekday">{day}</div>
))}
</div>
<div className="days">
{calendarDays.map((item, index) => (
<button
key={index}
className={`day-btn ${!item.isCurrentMonth ? "other-month" : ""} ${isInRange(item.date) ? "in-range" : ""} ${isRangeStart(item.date) ? "range-start" : ""} ${isRangeEnd(item.date) ? "range-end" : ""} ${isSelected(item.date) ? "selected" : ""} ${isToday(item.date) ? "today" : ""} ${isDisabled(item.date) ? "disabled" : ""}`}
onClick={() => !isDisabled(item.date) && handleDayClick(item.date)}
onMouseEnter={() => selecting === "to" && !isDisabled(item.date) && setHoverDate(item.date)}
onMouseLeave={() => setHoverDate(null)}
disabled={isDisabled(item.date)}
>
{item.day}
</button>
))}
</div>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1 @@
export {DateRangePicker as default} from './DateRangePicker';
@@ -0,0 +1,181 @@
@use "@/common/styles/colors" as *
.date-range-picker
position: relative
display: inline-block
.date-range-trigger
display: flex
align-items: center
gap: 0.5rem
padding: 0.5rem 0.875rem
background-color: $dark-gray
border: 1px solid $light-gray
border-radius: 0.5rem
cursor: pointer
transition: all 0.2s ease-in-out
&:hover
border-color: $accent-primary
.calendar-icon
color: $subtext
font-size: 0.875rem
.date-range-text
color: $white
font-size: 0.8rem
font-weight: 600
white-space: nowrap
.date-range-popover
position: absolute
top: calc(100% + 0.375rem)
left: 0
z-index: 1000
background-color: $dark-gray
border: 1px solid $light-gray
border-radius: 0.75rem
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25)
overflow: hidden
padding: 1rem
animation: popoverFadeIn 0.2s ease-out
@keyframes popoverFadeIn
from
opacity: 0
transform: translateY(-8px)
to
opacity: 1
transform: translateY(0)
.calendar-nav
display: flex
align-items: center
justify-content: space-between
margin-bottom: 0.75rem
.nav-btn
display: flex
align-items: center
justify-content: center
width: 1.75rem
height: 1.75rem
background: transparent
border: 1px solid $light-gray
border-radius: 0.375rem
color: $subtext
cursor: pointer
transition: all 0.15s ease
font-size: 0.75rem
&:hover:not(:disabled)
border-color: $accent-primary
color: $white
background-color: $darker-gray
&:disabled
opacity: 0.3
cursor: not-allowed
.current-month
color: $white
font-size: 0.8rem
font-weight: 600
.calendar-grid
min-width: 240px
.weekdays
display: grid
grid-template-columns: repeat(7, 1fr)
margin-bottom: 0.375rem
.weekday
text-align: center
color: $subtext
font-size: 0.625rem
font-weight: 600
text-transform: uppercase
padding: 0.375rem 0
.days
display: grid
grid-template-columns: repeat(7, 1fr)
.day-btn
display: flex
align-items: center
justify-content: center
width: 100%
height: 30px
background: transparent
border: none
color: $white
font-size: 0.75rem
font-weight: 600
cursor: pointer
transition: background-color 0.1s ease, color 0.1s ease
border-radius: 0
&:hover:not(.disabled):not(.selected):not(.in-range)
background-color: $darker-gray
border-radius: 0.25rem
&.other-month
color: $subtext
opacity: 0.4
&.in-range
background-color: rgba(16, 185, 129, 0.2)
&.range-start
background-color: $accent-primary
color: #fff
font-weight: 600
border-radius: 0.25rem 0 0 0.25rem
&.range-end
border-radius: 0.25rem
&.range-end
background-color: $accent-primary
color: #fff
font-weight: 600
border-radius: 0 0.25rem 0.25rem 0
&.today:not(.selected)
position: relative
font-weight: 600
&::after
content: ""
position: absolute
bottom: 3px
left: 50%
transform: translateX(-50%)
width: 3px
height: 3px
background-color: $accent-primary
border-radius: 50%
&.disabled
opacity: 0.25
cursor: not-allowed
@media screen and (max-width: 600px)
.date-range-popover
position: fixed
top: 50%
left: 50%
transform: translate(-50%, -50%)
max-width: calc(100vw - 2rem)
max-height: calc(100vh - 2rem)
overflow-y: auto
.calendar-grid
min-width: unset
.day-btn
height: 28px
font-size: 0.7rem
@@ -1,31 +1,27 @@
import React, {useContext, useEffect, useRef, useState} from "react";
import "./styles.sass";
import {
faArrowDown,
faArrowUp,
faCircleNodes,
faClock,
faGlobeEurope,
faInfo,
faKey,
faPause,
faPingPongPaddleBall,
faPlay,
faWandMagicSparkles,
faCheck,
faExclamationTriangle,
faSliders,
faHardDrive,
faMoon,
faSun
faSun,
faGauge
} from "@fortawesome/free-solid-svg-icons";
import {ConfigContext} from "@/common/contexts/Config";
import {StatusContext} from "@/common/contexts/Status";
import {InputDialogContext} from "@/common/contexts/InputDialog";
import {ThemeContext} from "@/common/contexts/Theme";
import {baseRequest, jsonRequest, patchRequest, postRequest} from "@/common/utils/RequestUtil";
import {creditsInfo, recommendationsInfo} from "@/common/components/Dropdown/utils/infos";
import {levelOptions, selectOptions} from "@/common/components/Dropdown/utils/options";
import {baseRequest, patchRequest, postRequest} from "@/common/utils/RequestUtil";
import {levelOptions, selectOptions} from "@/common/components/Dropdown/utils/options";
import {parseCron, stringifyCron} from "@/common/components/Dropdown/utils/utils";
import {t} from "i18next";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
@@ -35,6 +31,7 @@ import {IntegrationDialog} from "@/common/components/IntegrationDialog";
import LanguageDialog from "@/common/components/LanguageDialog";
import ProviderDialog from "@/common/components/ProviderDialog";
import StorageDialog from "@/common/components/StorageDialog";
import OptimalValuesDialog from "@/common/components/OptimalValuesDialog";
const DropdownComponent = ({isOpen, switchDropdown}) => {
const [config, reloadConfig] = useContext(ConfigContext);
@@ -49,6 +46,7 @@ const DropdownComponent = ({isOpen, switchDropdown}) => {
const [showLanguageDialog, setShowLanguageDialog] = useState(false);
const [showProviderDialog, setShowProviderDialog] = useState(false);
const [showStorageDialog, setShowStorageDialog] = useState(false);
const [showOptimalValuesDialog, setShowOptimalValuesDialog] = useState(false);
const ref = useRef();
useEffect(() => {
@@ -86,36 +84,6 @@ const DropdownComponent = ({isOpen, switchDropdown}) => {
}), 160);
}
const updatePing = async () => patchDialog("ping", (value) => ({
title: t("update.ping_title"), placeholder: t("update.ping_placeholder"), value
}));
const updateUpload = async () => patchDialog("upload", (value) => ({
title: t("update.upload_title"), placeholder: t("update.upload_placeholder"), value
}));
const updateDownload = async () => patchDialog("download", (value) => ({
title: t("update.download_title"), placeholder: t("update.download_placeholder"), value
}));
const recommendedSettings = async () => {
const result = await jsonRequest("/recommendations");
if (!result.message) {
setDialog({
title: t("update.recommendations_set"),
description: recommendationsInfo(result.ping, result.download, result.upload),
buttonText: t("dialog.apply"),
onSuccess: async () => {
await patchRequest("/config/ping", {value: result.ping});
await patchRequest("/config/download", {value: result.download});
await patchRequest("/config/upload", {value: result.upload});
showFeedback();
}
});
} else setDialog({title: t("update.recommendations_title"), description: t("info.recommendations_error"), buttonText: t("dialog.okay")});
}
const updatePassword = async () => {
const passwordSet = currentNode !== 0 ? findNode(currentNode).password : localStorage.getItem("password") != null;
@@ -177,8 +145,6 @@ const DropdownComponent = ({isOpen, switchDropdown}) => {
} else postRequest("/speedtests/continue").then(updateStatus);
}
const showCredits = () => setDialog({title: "MySpeed", description: creditsInfo(), buttonText: t("dialog.close")});
const showProviderDetails = () => setDialog({title: t("dropdown.provider"), description: config.previewMessage, buttonText: t("dialog.close")});
const handleThemeToggle = () => {
@@ -187,10 +153,7 @@ const DropdownComponent = ({isOpen, switchDropdown}) => {
};
const options = [
{run: updatePing, icon: faPingPongPaddleBall, text: t("dropdown.ping")},
{run: updateUpload, icon: faArrowUp, text: t("dropdown.upload")},
{run: updateDownload, icon: faArrowDown, text: t("dropdown.download")},
{run: recommendedSettings, icon: faWandMagicSparkles, text: t("dropdown.recommendations")},
{run: () => setShowOptimalValuesDialog(true), icon: faGauge, text: t("dropdown.optimal_values")},
{hr: true, key: 1},
{run: () => setShowProviderDialog(true), icon: faSliders, text: t("dropdown.change_provider")},
{run: () => setShowStorageDialog(true), icon: faHardDrive, text: t("dropdown.storage")},
@@ -201,7 +164,6 @@ const DropdownComponent = ({isOpen, switchDropdown}) => {
{hr: true, key: 2},
{run: handleThemeToggle, icon: isDarkMode ? faSun : faMoon, text: t(isDarkMode ? "dropdown.light_mode" : "dropdown.dark_mode"), allowView: true},
{run: () => setShowLanguageDialog(true), icon: faGlobeEurope, text: t("dropdown.language"), allowView: true},
{run: showCredits, icon: faInfo, text: t("dropdown.info"), allowView: true, previewHidden: true},
{run: showProviderDetails, icon: faInfo, text: t("dropdown.provider"), previewShown: true}
];
@@ -211,6 +173,7 @@ const DropdownComponent = ({isOpen, switchDropdown}) => {
{showLanguageDialog && <LanguageDialog onClose={() => setShowLanguageDialog(false)}/>}
{showProviderDialog && <ProviderDialog onClose={() => setShowProviderDialog(false)}/>}
{showStorageDialog && <StorageDialog onClose={() => setShowStorageDialog(false)}/>}
{showOptimalValuesDialog && <OptimalValuesDialog onClose={() => setShowOptimalValuesDialog(false)}/>}
<div className={`dropdown ${isOpen ? '' : 'dropdown-invisible'}`} ref={ref}>
<div className="dropdown-content">
<h2>{t("dropdown.settings")}</h2>
@@ -15,12 +15,14 @@
width: auto
overflow: auto
border-radius: 10px
box-shadow: 0 8px 16px 0 $dark-gray
box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.3)
border: 1px solid $light-gray
right: 0
z-index: 1
z-index: 200
padding: 15px
background-color: $dark-gray
backdrop-filter: blur(4px)
-webkit-backdrop-filter: blur(4px)
.dropdown-invisible
visibility: hidden
@@ -55,7 +57,7 @@
font-weight: 500
.dropdown-item:hover
color: $green
color: $accent-primary
.dropdown-item svg
width: 25px
@@ -1,9 +0,0 @@
import {DONATION_URL, PROJECT_URL, WEB_URL} from "@/index";
import {Trans} from "react-i18next";
export const creditsInfo = () => <Trans components={{MSpeed: <a href={WEB_URL} target="_blank" />,
Github: <a href={PROJECT_URL} target="_blank" />, Donate: <a href={DONATION_URL} target="_blank" />}}>info.credits</Trans>
export const recommendationsInfo = (ping, down, up) => <Trans components={{Bold: <span className="dialog-value" />}}
values={{ping, down, up}}>info.recommendations_info</Trans>
@@ -0,0 +1,49 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faChevronDown, faPlus} from "@fortawesome/free-solid-svg-icons";
import {useState, useRef} from "react";
import "./styles.sass";
export const DropdownSelect = ({
items,
onSelect,
buttonText,
buttonIcon = faPlus,
disabled = false
}) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef(null);
const handleBlur = (event) => {
if (!containerRef.current?.contains(event.relatedTarget)) {
setIsOpen(false);
}
};
const handleSelect = (item) => {
onSelect(item);
setIsOpen(false);
};
if (disabled) return null;
return (
<div className="dropdown-select-container" ref={containerRef} onBlur={handleBlur} tabIndex={-1}>
<button className="dropdown-select-btn" onClick={() => setIsOpen(!isOpen)}>
<FontAwesomeIcon icon={buttonIcon}/>
<span>{buttonText}</span>
<FontAwesomeIcon icon={faChevronDown} className={`dropdown-select-chevron ${isOpen ? "rotated" : ""}`}/>
</button>
{isOpen && (
<div className="dropdown-select-menu">
{items.map((item, index) => (
<div key={item.key || index} className="dropdown-select-item" onClick={() => handleSelect(item)} tabIndex={0}>
{item.icon && <FontAwesomeIcon icon={item.icon}/>}
<span>{item.label}</span>
</div>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1 @@
export {DropdownSelect as default} from "./DropdownSelect";
@@ -0,0 +1,68 @@
@use "@/common/styles/colors" as *
.dropdown-select-container
position: relative
display: flex
justify-content: flex-end
outline: none
.dropdown-select-btn
display: flex
align-items: center
gap: 0.5rem
padding: 0.625rem 1rem
background-color: $darker-gray
border: 1px solid $light-gray
border-radius: 0.5rem
color: $subtext
font-size: 0.875rem
font-weight: 500
cursor: pointer
transition: all 0.15s ease
&:hover
border-color: $accent-primary
color: $accent-primary
svg
font-size: 0.8rem
.dropdown-select-chevron
transition: transform 0.2s ease
margin-left: 0.25rem
&.rotated
transform: rotate(180deg)
.dropdown-select-menu
position: absolute
top: auto
bottom: calc(100% + 0.5rem)
right: 0
min-width: 12rem
background-color: $darker-gray
border: 1px solid $light-gray
border-radius: 0.5rem
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3)
z-index: 1000
overflow: hidden
.dropdown-select-item
display: flex
align-items: center
gap: 0.75rem
padding: 0.75rem 1rem
color: $subtext
cursor: pointer
transition: all 0.15s ease
&:hover
background-color: $light-gray
color: $white
svg
width: 1.1rem
font-size: 1rem
span
font-size: 0.9rem
@@ -0,0 +1,53 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faChevronDown, faChevronUp} from "@fortawesome/free-solid-svg-icons";
import {useState} from "react";
import "./styles.sass";
export const ExpandableCard = ({
icon,
title,
subtitle,
statusDot,
actions,
children,
defaultExpanded = false,
error = false,
success = false
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
return (
<div className={`expandable-card ${error ? "card-error" : ""} ${success ? "card-success" : ""}`}>
<div className="expandable-card-header" onClick={() => setExpanded(!expanded)}>
<div className="expandable-card-info">
{icon && (
<div className="expandable-card-icon">
<FontAwesomeIcon icon={icon}/>
</div>
)}
<div className="expandable-card-details">
<h3>{title}</h3>
{(subtitle || statusDot) && (
<div className="expandable-card-status">
{statusDot && <span className={`status-dot ${statusDot}`}/>}
{subtitle && <span className="status-text">{subtitle}</span>}
</div>
)}
</div>
</div>
<div className="expandable-card-actions">
{actions}
<button className="card-action-btn expand-btn">
<FontAwesomeIcon icon={expanded ? faChevronUp : faChevronDown}/>
</button>
</div>
</div>
{expanded && (
<div className="expandable-card-body">
{children}
</div>
)}
</div>
);
};
@@ -0,0 +1 @@
export {ExpandableCard as default} from "./ExpandableCard";
@@ -0,0 +1,131 @@
@use "@/common/styles/colors" as *
.expandable-card
background-color: $darker-gray
border: 1px solid $light-gray
border-radius: 0.75rem
transition: all 0.2s ease
&.card-error
border-color: $accent-danger
&.card-success
border-color: $accent-primary
.expandable-card-header
display: flex
align-items: center
justify-content: space-between
padding: 0.875rem 1rem
cursor: pointer
user-select: none
&:hover
background-color: rgba(255, 255, 255, 0.02)
border-radius: 0.75rem
.expandable-card-info
display: flex
align-items: center
gap: 0.75rem
.expandable-card-icon
display: flex
align-items: center
justify-content: center
width: 2.5rem
height: 2.5rem
background-color: $light-gray
border-radius: 0.5rem
color: $white
svg
font-size: 1.1rem
.expandable-card-details
h3
margin: 0
font-size: 0.95rem
font-weight: 600
color: $white
max-width: 12rem
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
.expandable-card-status
display: flex
align-items: center
gap: 0.35rem
margin-top: 0.2rem
.status-dot
width: 0.45rem
height: 0.45rem
border-radius: 50%
&.status-active
background-color: $accent-primary
&.status-inactive
background-color: $light-gray
&.status-error
background-color: $accent-danger
.status-text
font-size: 0.75rem
color: $subtext
.expandable-card-actions
display: flex
align-items: center
gap: 0.25rem
.card-action-btn
display: flex
align-items: center
justify-content: center
width: 2rem
height: 2rem
background: none
border: none
border-radius: 0.375rem
color: $subtext
cursor: pointer
transition: all 0.15s ease
&:hover
background-color: $light-gray
svg
font-size: 0.9rem
.card-action-btn.save-btn:hover
color: $accent-primary
.card-action-btn.delete-btn:hover
color: $accent-danger
.card-action-btn.delete-btn.confirm
color: $accent-danger
background-color: rgba(239, 68, 68, 0.1)
.card-action-btn.success-indicator
color: $accent-primary
cursor: default
&:hover
background: none
.expandable-card-body
padding: 1rem
display: flex
flex-direction: column
gap: 0.75rem
border-top: 1px solid $light-gray
margin-top: 0.5rem
@media (max-width: 600px)
.expandable-card-details h3
max-width: 8rem
@@ -0,0 +1,45 @@
import ToggleSwitch from "@/common/components/ToggleSwitch";
import "./styles.sass";
export const FormField = ({
label,
type = "text",
value,
onChange,
placeholder,
error = false,
disabled = false
}) => (
<div className="form-field">
<label className={error ? "form-field-error" : ""}>{label}</label>
{type === "text" && (
<input
type="text"
className={`form-field-input ${error ? "input-error" : ""}`}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
/>
)}
{type === "textarea" && (
<textarea
className={`form-field-input form-field-textarea ${error ? "input-error" : ""}`}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
/>
)}
{type === "boolean" && (
<ToggleSwitch
checked={value}
onChange={onChange}
disabled={disabled}
/>
)}
</div>
);
@@ -0,0 +1 @@
export {FormField as default} from "./FormField";
@@ -0,0 +1,55 @@
@use "@/common/styles/colors" as *
.form-field
display: flex
align-items: center
justify-content: space-between
gap: 1rem
label:first-child
font-size: 0.85rem
color: $subtext
flex: 1
.form-field-error
color: $accent-danger !important
.form-field-input
width: 55%
flex-shrink: 0
background-color: $dark-gray
border: 1px solid $light-gray
border-radius: 0.5rem
padding: 0.5rem 0.75rem
font-size: 0.85rem
color: $white
font-family: inherit
transition: border-color 0.15s ease
&:focus
outline: none
border-color: $accent-primary
&.input-error
border-color: $accent-danger
&::placeholder
color: $subtext
opacity: 0.6
&:disabled
opacity: 0.5
cursor: not-allowed
.form-field-textarea
min-height: 4rem
resize: vertical
@media (max-width: 600px)
.form-field
flex-direction: column
align-items: flex-start
gap: 0.5rem
.form-field-input
width: 100%
@@ -5,8 +5,8 @@ import {
faGaugeHigh,
faGear,
faLock,
faServer,
faClose
faClose,
faServer
} from "@fortawesome/free-solid-svg-icons";
import { useContext, useEffect, useState } from "react";
import DropdownComponent from "../Dropdown/DropdownComponent";
@@ -17,12 +17,12 @@ import { jsonRequest, postRequest } from "@/common/utils/RequestUtil";
import { updateInfo } from "@/common/components/Header/utils/infos";
import { t } from "i18next";
import { ConfigContext } from "@/common/contexts/Config";
import { LoadingDialog } from "@/common/components/LoadingDialog";
import { NodeContext } from "@/common/contexts/Node";
import { WEB_URL } from "@/index";
import { Trans } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import Pagination from "./components/Pagination";
import AboutDialog from "@/common/components/AboutDialog";
const HeaderComponent = () => {
const findNode = useContext(NodeContext)[4];
@@ -32,12 +32,12 @@ const HeaderComponent = () => {
const [setDialog] = useContext(InputDialogContext);
const [icon, setIcon] = useState(faGear);
const [status, updateStatus] = useContext(StatusContext);
const [startedManually, setStartedManually] = useState(false);
const [status, updateStatus, setRunning] = useContext(StatusContext);
const {updateTests} = useContext(SpeedtestContext);
const [config, reloadConfig, checkConfig] = useContext(ConfigContext);
const [updateAvailable, setUpdateAvailable] = useState("");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [showAboutDialog, setShowAboutDialog] = useState(false);
const switchDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
@@ -74,15 +74,10 @@ const HeaderComponent = () => {
buttonText: t("dialog.okay")
});
if (status.running) return setDialog({
title: t("failed"),
description: t("header.running"),
buttonText: t("dialog.okay")
});
if (status.running) return;
setStartedManually(true);
postRequest("/speedtests/run").then(updateTests).then(updateStatus).then(() => setStartedManually(false));
setRunning(true);
postRequest("/speedtests/run").then(updateTests).then(updateStatus);
}
const openDownloadPage = () => window.open(WEB_URL + "/install", "_blank");
@@ -106,11 +101,11 @@ const HeaderComponent = () => {
return (
<header>
<LoadingDialog isOpen={startedManually} />
{showAboutDialog && <AboutDialog onClose={() => setShowAboutDialog(false)}/>}
<div className="header-main">
<div className="header-left">
{config.viewMode && <h2>{t("header.title")}</h2>}
{!config.viewMode && <h2 onClick={() => navigate("/nodes")} className="h2-click"><FontAwesomeIcon icon={faServer} /> {getNodeName()}</h2>}
{!config.viewMode && <h2 className="header-about" onClick={() => setShowAboutDialog(true)}><img src="/assets/img/logo192.png" alt="MySpeed Logo" className="header-logo" /> {getNodeName()}</h2>}
{config.previewMode && <h2 className="demo-info" onClick={showDemoDialog}>{t("preview.info")}</h2>}
</div>
@@ -143,6 +138,11 @@ const HeaderComponent = () => {
<span className="tooltip">{t("header.download")}</span>
</div> : <></>)}
{!config.viewMode && <div className="tooltip-element tooltip-bottom">
<FontAwesomeIcon icon={faServer} className="header-icon" onClick={() => navigate("/nodes")} />
<span className="tooltip">{t("header.servers")}</span>
</div>}
<div className="tooltip-element tooltip-bottom" id="open-header">
<FontAwesomeIcon icon={icon} className="header-icon" onClick={switchDropdown} />
<span className="tooltip">{t("dropdown.settings")}</span>
@@ -2,7 +2,7 @@ import "./styles.sass";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChartArea, faListUl } from "@fortawesome/free-solid-svg-icons";
import { useLocation, useNavigate } from "react-router-dom";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import {t} from "i18next";
export const Pagination = () => {
@@ -10,26 +10,31 @@ export const Pagination = () => {
const location = useLocation();
const [activeIndex, setActiveIndex] = useState(location.pathname === "/" ? 0 : 1);
const paginationRef = useRef(null);
const activeItemRef = useRef(null);
const itemRefs = useRef([]);
useEffect(() => {
const currentIndex = location.pathname === "/" ? 0 : 1;
setActiveIndex(currentIndex);
}, [location.pathname]);
useEffect(() => {
const updateActiveBackground = () => {
if (paginationRef.current && activeItemRef.current) {
const { offsetLeft, offsetWidth } = activeItemRef.current;
paginationRef.current.style.setProperty('--active-left', `${offsetLeft}px`);
paginationRef.current.style.setProperty('--active-width', `${offsetWidth}px`);
}
};
const updateActiveBackground = useCallback(() => {
if (paginationRef.current && itemRefs.current[activeIndex]) {
const { offsetLeft, offsetWidth } = itemRefs.current[activeIndex];
paginationRef.current.style.setProperty('--active-left', `${offsetLeft}px`);
paginationRef.current.style.setProperty('--active-width', `${offsetWidth}px`);
}
}, [activeIndex]);
useEffect(() => {
updateActiveBackground();
if (document.fonts?.ready) {
document.fonts.ready.then(updateActiveBackground);
}
window.addEventListener('resize', updateActiveBackground);
return () => window.removeEventListener('resize', updateActiveBackground);
}, [activeIndex]);
}, [updateActiveBackground]);
return (
<div className="pagination" ref={paginationRef}>
@@ -39,7 +44,7 @@ export const Pagination = () => {
navigate("/");
setActiveIndex(0);
}}
ref={activeIndex === 0 ? activeItemRef : null}
ref={el => itemRefs.current[0] = el}
>
<FontAwesomeIcon icon={faListUl}/>
<p>{t("page.overview")}</p>
@@ -50,7 +55,7 @@ export const Pagination = () => {
navigate("/statistics");
setActiveIndex(1);
}}
ref={activeIndex === 1 ? activeItemRef : null}
ref={el => itemRefs.current[1] = el}
>
<FontAwesomeIcon icon={faChartArea}/>
<p>{t("page.statistics")}</p>
@@ -3,7 +3,9 @@
.pagination
display: flex
justify-content: center
background-color: $dark-gray
background-color: var(--glass-bg)
backdrop-filter: blur(16px)
-webkit-backdrop-filter: blur(16px)
border: 2px solid $light-gray
padding: 0.7rem 0.8rem
gap: 1rem
+27 -34
View File
@@ -1,5 +1,9 @@
@use "@/common/styles/colors" as *
header
position: relative
z-index: 200
.header-main
margin-top: 3rem
display: grid
@@ -33,60 +37,62 @@
justify-content: center
font-size: 14pt
background-color: $dark-gray
color: $green
color: $accent-primary
margin: 0
.header-main h2
display: flex
align-items: center
gap: 1rem
padding: 0.5rem 1rem
border-radius: 1rem
border-radius: 0.75rem
margin: 0
.header-main .h2-click
display: flex
gap: 1rem
align-items: center
cursor: pointer
.header-logo
width: 32px
height: 32px
border-radius: 6px
.header-about
cursor: help
user-select: none
transition: color 0.2s ease
svg
margin-right: 0.3rem
.header-main .h2-click:hover
color: $green
background-color: $dark-gray
&:hover
color: $accent-primary
.header-icon
cursor: pointer
display: flex
transition: all 50ms ease-in-out
transition: all 0.15s ease-in-out
width: 30px
height: 30px
color: $subtext
.header-icon:hover
transform: scale(1.1)
color: $green
color: $accent-primary
.test-running
animation: pulse 2s infinite
border-radius: 150px
color: $green
color: $accent-primary
.update-icon
animation: pulse-update 2s infinite
border-radius: 150px
.update-icon:hover
color: $orange
color: $accent-warning
filter: brightness(1.2)
@keyframes pulse-update
0%
box-shadow: 0 0 0 0 $orange
box-shadow: 0 0 0 0 $accent-warning
70%
box-shadow: 0 0 0 10px #CCA92C00
box-shadow: 0 0 0 10px transparent
100%
box-shadow: 0 0 0 0 #CCA92C00
box-shadow: 0 0 0 0 transparent
@media (max-width: 768px)
.header-main
@@ -99,29 +105,16 @@
@media (max-width: 530px)
.header-left div
margin-right: 0
.header-left .h2-click
margin-left: 0
font-size: 22pt
.header-icon
width: 25px
height: 25px
@media (max-width: 480px)
.header-left .h2-click
font-size: 14pt
text-overflow: ellipsis
overflow: hidden
.header-left svg
font-size: 14pt
.header-icon
width: 20px
height: 20px
@media screen and (max-width: 365px)
.header-main .h2-click
gap: 0
.header-left svg
margin-right: 0
.demo-info
font-size: 12pt
padding: 0
@@ -2,25 +2,206 @@ import {DialogContext, DialogProvider} from "@/common/contexts/Dialog";
import "./styles.sass";
import React, {useContext, useEffect, useState} from "react";
import {t} from "i18next";
import {faClose, faExclamationTriangle} from "@fortawesome/free-solid-svg-icons";
import i18n from "i18next";
import {
faCheck,
faCircleNodes,
faClose,
faExclamationTriangle,
faFloppyDisk,
faTrash,
faTrashArrowUp
} from "@fortawesome/free-solid-svg-icons";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {jsonRequest} from "@/common/utils/RequestUtil";
import IntegrationItem from "@/common/components/IntegrationDialog/components/IntegrationItem";
import {deleteRequest, jsonRequest, patchRequest, putRequest} from "@/common/utils/RequestUtil";
import {v4 as uuid} from 'uuid';
import AvailableIntegrations from "./components/AvailableIntegrations";
import IntegrationAddButton from "@/common/components/IntegrationDialog/components/IntegrationAddButton";
import NoIntegrationsTab from "@/common/components/IntegrationDialog/components/NoIntegrationsTab";
import {ConfigContext} from "@/common/contexts/Config";
import {generateRelativeTime} from "@/pages/Home/components/LatestTest/utils";
import FormField from "@/common/components/FormField";
import ExpandableCard from "@/common/components/ExpandableCard";
import DropdownSelect from "@/common/components/DropdownSelect";
export const Dialog = ({integrations, active, setActive}) => {
const IntegrationCard = ({integration, integrationDef, onRemove, onUpdate}) => {
const [config] = useContext(ConfigContext);
const [displayName, setDisplayName] = useState(integration.displayName || t(`integrations.${integration.name}.title`));
const [fields, setFields] = useState(() => {
const initial = {};
integrationDef.fields.forEach(field => {
initial[field.name] = integration.data?.[field.name] ?? (field.type === "boolean" ? false : "");
});
return initial;
});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [saveConfirmed, setSaveConfirmed] = useState(false);
const [deleteConfirmed, setDeleteConfirmed] = useState(false);
const [error, setError] = useState(false);
const [lastActivity, setLastActivity] = useState(generateRelativeTime(integration.lastActivity));
useEffect(() => {
const interval = setInterval(() => {
if (integration.lastActivity) setLastActivity(generateRelativeTime(integration.lastActivity));
}, 1000);
return () => clearInterval(interval);
}, [integration.lastActivity]);
const updateField = (name, value) => {
setFields(prev => ({...prev, [name]: value}));
setUnsavedChanges(true);
};
const updateDisplayName = (value) => {
setDisplayName(value);
setUnsavedChanges(true);
};
const isValidInput = (field) => {
const value = fields[field.name];
if (field.required && !value) return false;
if (field.regex && value && !new RegExp(field.regex).test(value)) return false;
if (field.type === "text" && value && value.length > 255) return false;
if (field.type === "textarea" && value && value.length > 2000) return false;
return true;
};
const getPlaceholder = (fieldName) => {
const placeholderKey = `integrations.${integration.name}.fields.${fieldName}_placeholder`;
const baseKey = `integrations.${integration.name}.fields.${fieldName}`;
return i18n.exists(placeholderKey) ? t(placeholderKey) : t(baseKey);
};
const handleSave = async () => {
const data = {...fields, integration_name: displayName};
try {
if (!integration.id) {
const response = await putRequest(`/integrations/${integration.name}`, data);
if (!response.ok) throw new Error();
const result = await response.json();
onUpdate(integration.uuid, {id: result.id, isNew: false});
} else {
const response = await patchRequest(`/integrations/${integration.id}`, data);
if (!response.ok) throw new Error();
}
setUnsavedChanges(false);
setSaveConfirmed(true);
setError(false);
setTimeout(() => setSaveConfirmed(false), 1500);
} catch {
setError(true);
}
};
const handleDelete = async () => {
if (!deleteConfirmed) {
setDeleteConfirmed(true);
setTimeout(() => setDeleteConfirmed(false), 3000);
return;
}
if (!integration.id) {
onRemove(integration.uuid);
return;
}
await deleteRequest(`/integrations/${integration.id}`);
onRemove(integration.uuid);
};
const getStatusClass = () => {
if (integration.activityFailed) return "status-error";
if (!integration.lastActivity) return "status-inactive";
return "status-active";
};
const getStatusText = () => {
if (integration.activityFailed) return t("failed");
if (!integration.lastActivity) return t("integrations.activity.never_executed");
return t("integrations.activity.last_run") + lastActivity;
};
const cardActions = (
<>
{!config.previewMode && unsavedChanges && !saveConfirmed && (
<button className="card-action-btn save-btn" onClick={(e) => {e.stopPropagation(); handleSave();}}>
<FontAwesomeIcon icon={faFloppyDisk}/>
</button>
)}
{saveConfirmed && (
<span className="card-action-btn success-indicator">
<FontAwesomeIcon icon={faCheck}/>
</span>
)}
{!config.previewMode && (
<button className={`card-action-btn delete-btn ${deleteConfirmed ? "confirm" : ""}`}
onClick={(e) => {e.stopPropagation(); handleDelete();}}>
<FontAwesomeIcon icon={deleteConfirmed ? faTrashArrowUp : faTrash}/>
</button>
)}
</>
);
return (
<ExpandableCard
icon={integrationDef.icon}
title={displayName}
subtitle={getStatusText()}
statusDot={getStatusClass()}
actions={cardActions}
defaultExpanded={integration.isNew || false}
error={error}
success={saveConfirmed}
>
<FormField
label={t("integrations.display_name")}
type="text"
value={displayName}
onChange={updateDisplayName}
placeholder={t("integrations.display_name")}
/>
{integrationDef.fields.map((field) => (
<FormField
key={field.name}
label={t(`integrations.${integration.name}.fields.${field.name}`)}
type={field.type}
value={fields[field.name]}
onChange={(value) => updateField(field.name, value)}
placeholder={getPlaceholder(field.name)}
error={!isValidInput(field)}
/>
))}
</ExpandableCard>
);
};
const Dialog = ({integrations, active, setActive}) => {
const close = useContext(DialogContext);
const [config] = useContext(ConfigContext);
const [currentTab, setCurrentTab] = useState(integrations[Object.keys(integrations)[0]].name);
const addIntegration = () => setActive([...active, {uuid: uuid(), name: currentTab,
integration: integrations[currentTab], data: {}, open: true}]);
const addIntegration = (item) => {
setActive([...active, {
uuid: uuid(),
name: item.key,
data: {},
isNew: true
}]);
};
const deleteIntegration = (uuid) => setActive(active.filter((item) => item.uuid !== uuid));
const removeIntegration = (uuid) => {
setActive(active.filter(item => item.uuid !== uuid));
};
const updateIntegration = (uuid, updates) => {
setActive(active.map(item =>
item.uuid === uuid ? {...item, ...updates} : item
));
};
const dropdownItems = Object.entries(integrations).map(([name, def]) => ({
key: name,
label: t(`integrations.${name}.title`),
icon: def.icon
}));
return (
<>
@@ -28,51 +209,71 @@ export const Dialog = ({integrations, active, setActive}) => {
<h4 className="dialog-text">{t("dropdown.integrations")}</h4>
<FontAwesomeIcon icon={faClose} className="dialog-text dialog-icon" onClick={() => close()}/>
</div>
<div className="integration-dialog">
<AvailableIntegrations integrations={integrations} currentTab={currentTab}
setCurrentTab={setCurrentTab}/>
<div className="integrations-tab">
{config.previewMode && active.filter(item => item.name === currentTab).length > 0
&& <div className="pr-integration-container">
<FontAwesomeIcon icon={faExclamationTriangle} />
<p>{t("integrations.preview_active")}</p>
</div>}
<div className="integrations-wrapper">
{config.previewMode && active.length > 0 && (
<div className="preview-warning">
<FontAwesomeIcon icon={faExclamationTriangle}/>
<span>{t("integrations.preview_active")}</span>
</div>
)}
{active.map((item) => (item.name !== currentTab ? undefined :
<IntegrationItem integration={integrations[currentTab]}
remove={() => deleteIntegration(item.uuid)}
data={item} key={item.uuid} isOpen={item.open} />))}
{active.filter(item => item.name === currentTab).length === 0
&& <NoIntegrationsTab onClick={addIntegration} integration={integrations[currentTab]}/>}
{active.filter(item => item.name === currentTab).length > 0
&& <IntegrationAddButton onClick={addIntegration}/>}
</div>
{active.length === 0 ? (
<div className="empty-state">
<FontAwesomeIcon icon={faCircleNodes}/>
<p>{t("integrations.none_active").replace("<br/>", " ").replace("<Bold>", "").replace("</Bold>", "")}</p>
<DropdownSelect
items={dropdownItems}
onSelect={addIntegration}
buttonText={t("integrations.create")}
disabled={config.previewMode}
/>
</div>
) : (
<>
<div className="integrations-list">
{active.map(item => (
<IntegrationCard
key={item.uuid}
integration={item}
integrationDef={integrations[item.name]}
onRemove={removeIntegration}
onUpdate={updateIntegration}
/>
))}
</div>
<DropdownSelect
items={dropdownItems}
onSelect={addIntegration}
buttonText={t("integrations.create")}
disabled={config.previewMode}
/>
</>
)}
</div>
</>
);
}
};
export const IntegrationDialog = (props) => {
const [integrationData, setIntegrationData] = useState(undefined);
const [activeData, setActiveData] = useState(undefined);
useEffect(() => {
jsonRequest("/integrations")
.then(data => setIntegrationData(data));
jsonRequest("/integrations/active")
.then(data => setActiveData(data.map(item => ({...item, uuid: uuid()}))));
jsonRequest("/integrations").then(data => setIntegrationData(data));
jsonRequest("/integrations/active").then(data =>
setActiveData(data.map(item => ({...item, uuid: uuid()})))
);
}, []);
return (
<>
<DialogProvider close={props.onClose} customClass={(!integrationData || !activeData) ? "dialog-loading" : ""}>
{(!integrationData || !activeData) && <div className="lds-ellipsis"><div/><div/><div/><div/></div>}
{integrationData && activeData && <Dialog integrations={integrationData} active={activeData} setActive={setActiveData}/>}
</DialogProvider>
</>
)
}
<DialogProvider close={props.onClose} customClass={(!integrationData || !activeData) ? "dialog-loading" : ""}>
{(!integrationData || !activeData) && (
<div className="lds-ellipsis"><div/><div/><div/><div/></div>
)}
{integrationData && activeData && (
<Dialog integrations={integrationData} active={activeData} setActive={setActiveData}/>
)}
</DialogProvider>
);
};
@@ -1,16 +0,0 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import React from "react";
import {t} from "i18next";
import "./styles.sass";
export const AvailableIntegrations = ({integrations, currentTab, setCurrentTab}) => (
<div className="available-integrations">
{Object.keys(integrations).map((key) => <div
className={"integration-tab" + (key === currentTab ? " integration-active" : "")} key={key}
onClick={() => setCurrentTab(key)}>
<FontAwesomeIcon icon={integrations[key].icon} className="integration-icon"/>
<p className="integration-text">{t(`integrations.${key}.title`)}</p>
</div>)}
</div>
)
@@ -1 +0,0 @@
export {AvailableIntegrations as default} from './AvailableIntegrations';
@@ -1,37 +0,0 @@
@use "@/common/styles/colors" as *
.available-integrations
display: flex
flex-direction: column
gap: 0.5rem
user-select: none
overflow-x: hidden
overflow-y: scroll
.integration-tab
display: flex
align-items: center
gap: 0.5rem
color: $subtext
padding: 0.6rem 0.7rem
border: 2px solid transparent
border-radius: 1rem
cursor: pointer
svg
width: 1.3rem
height: 1.3rem
p
margin: 0
font-size: 14pt
.integration-active
background-color: $light-gray
color: $white
@media (max-width: 781px)
.available-integrations
flex-direction: row
overflow-x: scroll
overflow-y: hidden
gap: 0.5rem
@@ -1,14 +0,0 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faAdd} from "@fortawesome/free-solid-svg-icons";
import {t} from "i18next";
import React from "react";
import "./styles.sass";
export const IntegrationAddButton = ({onClick}) => (
<div className="add-container">
<div className="add-integration" onClick={onClick}>
<FontAwesomeIcon icon={faAdd}/>
<p>{t("integrations.create")}</p>
</div>
</div>
)
@@ -1 +0,0 @@
export {IntegrationAddButton as default} from "./IntegrationAddButton";
@@ -1,23 +0,0 @@
@use "@/common/styles/colors" as *
.add-container
width: 100%
display: flex
justify-content: flex-end
.add-integration
color: $subtext
cursor: pointer
border-radius: 0.8rem
display: flex
align-items: center
justify-content: center
gap: 0.5rem
padding: 0.7rem 0.8rem
user-select: none
p
margin: 0
&:hover
background-color: $light-gray
@@ -1,126 +0,0 @@
import i18n, {t} from "i18next";
import {useState} from "react";
import {deleteRequest, patchRequest, putRequest} from "@/common/utils/RequestUtil";
import IntegrationItemHeader from "./components/IntegrationItemHeader";
import "./styles.sass";
export const IntegrationItem = ({integration, data, remove, isOpen}) => {
const [open, setOpen] = useState(isOpen);
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [deleteConfirmed, setDeleteConfirmed] = useState(false);
const [changesConfirmed, setChangesConfirmed] = useState(false);
const [displayName, setDisplayName] = useState(data.displayName || t(`integrations.${integration.name}.title`));
const [error, setError] = useState(false);
const [identifier, setIdentifier] = useState(data.id);
const states = integration.fields.map(field => {
return {name: field.name, value: useState(data.data[field.name] || (field.type === "boolean" ? false : ""))};
});
const updateDisplayName = (event) => {
setDisplayName(event.target.value);
setUnsavedChanges(true);
}
const updateState = (index, value) => {
setUnsavedChanges(true);
states[index].value[1](value);
}
const processConfirmed = () => {
setUnsavedChanges(false);
setChangesConfirmed(true);
setTimeout(() => setChangesConfirmed(false), 1000);
}
const saveIntegration = () => {
const updatedData = {};
states.forEach(state => updatedData[state.name] = state.value[0]);
updatedData["integration_name"] = displayName;
if (!identifier) {
putRequest(`/integrations/${integration.name}`, updatedData).then(data => {
if (!data.ok) throw new Error();
return data.json();
}).then(processed => {
setIdentifier(processed.id);
processConfirmed();
setError(false);
}).catch(() => setError(true));
} else {
patchRequest(`/integrations/${identifier}`, updatedData).then(data => {
if (!data.ok) throw new Error();
}).then(() => {
processConfirmed();
setError(false);
}).catch(() => setError(true));
}
}
const deleteIntegration = () => {
if (!deleteConfirmed) {
setDeleteConfirmed(true);
return;
}
if (!identifier) {
remove();
return;
}
deleteRequest(`/integrations/${identifier}`)
.then(() => remove());
}
const isValidInput = (field, index) => {
if (field.required && !getState(index)) return false;
if (field.regex && !new RegExp(field.regex).test(getState(index))) return false;
if (field.type === "text" && getState(index).length > 255) return false;
return !(field.type === "textarea" && getState(index).length > 2000);
}
const getState = (index) => states[index].value[0];
const getPlaceholder = (integration, field) => t(`integrations.${integration}.fields.${field}`
+ (i18n.exists(`integrations.${integration}.fields.${field}_placeholder`) ? "_placeholder" : ""))
return (
<div className={"integration-item"+(error ? " item-error-border" : (changesConfirmed ? " green-border" : ""))}>
<IntegrationItemHeader open={open} setOpen={setOpen} displayName={displayName}
changesConfirmed={changesConfirmed} deleteConfirmed={deleteConfirmed}
deleteIntegration={deleteIntegration} saveIntegration={saveIntegration}
unsavedChanges={unsavedChanges} integration={integration} data={data}/>
{open && <div className="integration-body">
<div className="integration-field">
<p className="integration-field-title">{t(`integrations.display_name`)}</p>
<input className="integration-field-input" type="text" value={displayName}
onChange={updateDisplayName} placeholder={t(`integrations.display_name`)}/>
</div>
{integration.fields.map((field, index) => <div className="integration-field" key={index}>
<p className={"integration-field-title" + (!isValidInput(field, index)
? " error-item" : "")}>{t(`integrations.${integration.name}.fields.${field.name}`)}</p>
{field.type === "text" && <input className={"integration-field-input" + (!isValidInput(field, index)
? " item-error-border" : "")} type="text" value={getState(index)}
onChange={e => updateState(index, e.target.value)}
placeholder={getPlaceholder(integration.name, field.name)}/>}
{field.type === "textarea" && <textarea className={"integration-field-input text-area"
+ (!isValidInput(field, index) ? " item-error-border" : "")} value={getState(index)}
onChange={e => updateState(index, e.target.value)}
placeholder={getPlaceholder(integration.name, field.name)} />}
{field.type === "boolean" && <input type="checkbox" checked={getState(index)}
onChange={e => updateState(index, e.target.checked)}/>}
</div>)}
</div>}
</div>
)
}
@@ -1,58 +0,0 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {t} from "i18next";
import {generateRelativeTime} from "@/pages/Home/components/LatestTest/utils";
import {
faCheck,
faChevronDown,
faChevronUp,
faFloppyDisk,
faTrash,
faTrashArrowUp
} from "@fortawesome/free-solid-svg-icons";
import "./styles.sass";
import {useContext, useEffect, useState} from "react";
import {ConfigContext} from "@/common/contexts/Config";
export const IntegrationItemHeader = ({integration, displayName, unsavedChanges, changesConfirmed, saveIntegration,
deleteConfirmed, deleteIntegration, open, setOpen, data}) => {
const [lastActivity, setLastActivity] = useState(generateRelativeTime(data.lastActivity));
const [config] = useContext(ConfigContext);
useEffect(() => {
const interval = setInterval(() => {
if (data.lastActivity !== null) setLastActivity(generateRelativeTime(data.lastActivity));
}, 1000);
return () => clearInterval(interval);
});
return (
<div className="integration-item-header">
<div className="integration-item-left">
<FontAwesomeIcon icon={integration.icon} className="integration-item-icon"/>
<div className="integration-title-container">
<h3>{displayName}</h3>
<div className="integration-item-activity">
<div className={"integration-item-activity-circle circle-" + (data.activityFailed ? "error"
: (data.lastActivity === null || !data.lastActivity ? "inactive" : "active"))}/>
<p>{data.activityFailed ? t("failed") : (data.lastActivity === null || !data.lastActivity
? t("integrations.activity.never_executed") : t("integrations.activity.last_run") + lastActivity)}</p>
</div>
</div>
</div>
<div className="integration-item-right">
{!config.previewMode && unsavedChanges && !changesConfirmed && <FontAwesomeIcon icon={faFloppyDisk} onClick={saveIntegration}
className="integration-green"/>}
{changesConfirmed && <FontAwesomeIcon icon={faCheck} className="icon-green"/>}
{!config.previewMode && !deleteConfirmed &&
<FontAwesomeIcon icon={faTrash} className="integration-red" onClick={deleteIntegration}/>}
{deleteConfirmed &&
<FontAwesomeIcon icon={faTrashArrowUp} className="integration-red" onClick={deleteIntegration}/>}
<FontAwesomeIcon icon={open ? faChevronUp : faChevronDown} onClick={() => setOpen(!open)}
className="integration-green"/>
</div>
</div>
)}
@@ -1 +0,0 @@
export {IntegrationItemHeader as default} from "./IntegrationItemHeader";
@@ -1,61 +0,0 @@
@use "@/common/styles/colors" as *
.integration-item-header
display: flex
justify-content: space-between
.integration-item-left
display: flex
align-items: center
gap: 0.5rem
svg
width: 1.8rem
height: 1.8rem
.integration-title-container h3
margin: 0
font-size: 17px
text-overflow: ellipsis
overflow: hidden
white-space: nowrap
max-width: 11rem
.integration-item-activity
display: flex
align-items: center
gap: 0.3rem
.integration-item-activity-circle
width: 0.5rem
height: 0.5rem
border-radius: 5rem
p
margin: 0
font-size: 11pt
font-weight: 500
.circle-error
background-color: $red
.circle-inactive
background-color: $dark-gray
.circle-active
background-color: $green
.integration-item-right
display: flex
align-items: center
gap: 0.8rem
.integration-item-right svg
width: 1.5rem
height: 3rem
cursor: pointer
font-weight: 1000
.integration-green:hover
color: $green
.integration-red:hover
color: $red
@@ -1 +0,0 @@
export {IntegrationItem as default} from "./IntegrationItem";
@@ -1,75 +0,0 @@
@use "@/common/styles/colors" as *
.integration-item
border: 2px solid $light-gray
border-radius: 0.8rem
padding: 0.5rem 1.5rem
color: $subtext
transition: border-color 0.3s
.green-border
border: 2px solid $green
.error-border
border: 2px solid $red
.integration-body
margin-left: 0.5rem
margin-right: 0.5rem
.integration-field
display: flex
align-items: center
gap: 0.5rem
margin-top: 0.5rem
justify-content: space-between
p
margin: 0
.error-item
color: $red
.integration-field-input
background-color: $darker-gray
border: 1px solid $light-gray
border-radius: 0.5rem
padding: 0.1rem 0.8rem
color: $subtext
font-size: 12pt
height: 2rem
width: 50%
.item-error-border
border: 1px solid $red
.text-area
font-family: "Inter", sans-serif
height: 3.5rem
resize: none
input[type="checkbox"]
appearance: none
border: 1px solid $light-gray
width: 2rem
height: 2rem
border-radius: 0.5rem
outline: none
transition: border-color 0.3s
background-color: $darker-gray
cursor: pointer
&:checked
content: "\2713"
color: $green
display: flex
justify-content: center
align-items: center
font-weight: 700
font-size: 18pt
&::before
color: $green
&:checked::before
content: "\2713"
@@ -1,13 +0,0 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {Trans} from "react-i18next";
import React from "react";
import "./styles.sass";
export const NoIntegrationsTab = ({onClick, integration}) => (
<div className="no-integrations">
<FontAwesomeIcon icon={integration.icon}/>
<p className="dialog-text"><Trans
components={{Bold: <span className="integration-add" onClick={onClick}/>}}>
integrations.none_active</Trans></p>
</div>
)
@@ -1 +0,0 @@
export {NoIntegrationsTab as default} from "./NoIntegrationsTab";
@@ -1,19 +0,0 @@
@use "@/common/styles/colors" as *
.no-integrations
display: flex
justify-content: center
align-items: center
flex-direction: column
gap: 1rem
height: 100%
text-align: center
svg
width: 3rem
height: 3rem
color: $subtext
.integration-add
color: $green
cursor: pointer
@@ -1,46 +1,55 @@
@use "@/common/styles/colors" as *
.integration-dialog
display: flex
margin-left: 0.5rem
margin-right: 0.5rem
gap: 1rem
width: 45rem
margin-top: 1rem
height: 20rem
.integrations-tab
.integrations-wrapper
display: flex
flex-direction: column
gap: 1rem
margin-top: 1rem
width: 32rem
.preview-warning
display: flex
align-items: center
gap: 0.5rem
overflow-y: scroll
overflow-x: clip
width: 70%
padding: 0.75rem 1rem
background-color: rgba(239, 68, 68, 0.1)
border: 1px solid $accent-danger
border-radius: 0.5rem
color: $accent-danger
font-size: 0.9rem
font-weight: 500
.pr-integration-container
display: flex
gap: 0.5rem
align-items: center
justify-content: center
padding: 0.5rem
border-radius: 0.5rem
border: 2px solid $red
color: $red
font-size: 1.25rem
svg
flex-shrink: 0
p
margin: 0
font-weight: 500
.empty-state
display: flex
flex-direction: column
align-items: center
justify-content: center
gap: 1rem
padding: 3rem 2rem
text-align: center
color: $subtext
@media (max-width: 781px)
.integration-dialog
width: 90vw
margin-left: 0
height: 100%
margin-right: 0
margin-top: 0.5rem
flex-direction: column
svg
font-size: 3rem
opacity: 0.6
.integrations-tab
max-height: 15rem
width: 100%
p
margin: 0
font-size: 1rem
.integrations-list
display: flex
flex-direction: column
gap: 0.75rem
max-height: 20rem
overflow-y: auto
@media (max-width: 600px)
.integrations-wrapper
width: 85vw
.integrations-list
max-height: 50vh
@@ -1,15 +0,0 @@
import {DialogProvider} from "@/common/contexts/Dialog";
import "./styles.sass";
export const LoadingDialog = (props) => {
return (
<>
{props.isOpen && <DialogProvider disableClosing={true} customClass="dialog-loading">
<div className="lds-ellipsis">
<div/><div/><div/><div/>
</div>
</DialogProvider>}
</>
)
}
@@ -1 +0,0 @@
export * from './LoadingDialog';
@@ -1,9 +0,0 @@
@use "@/common/styles/colors" as *
.dialog-loading
display: flex
justify-content: center
align-content: center
align-items: center
width: 70px
height: 70px
@@ -0,0 +1,116 @@
import {DialogContext, DialogProvider} from "@/common/contexts/Dialog";
import {t} from "i18next";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faArrowDown, faArrowUp, faCheck, faClose, faExclamationTriangle, faTableTennis, faWandMagicSparkles} from "@fortawesome/free-solid-svg-icons";
import "./styles.sass";
import React, {useContext, useEffect, useState} from "react";
import {jsonRequest, patchRequest} from "@/common/utils/RequestUtil";
import {ConfigContext} from "@/common/contexts/Config";
import {ToastNotificationContext} from "@/common/contexts/ToastNotification";
export const Dialog = () => {
const close = useContext(DialogContext);
const [config, reloadConfig] = useContext(ConfigContext);
const updateToast = useContext(ToastNotificationContext);
const [ping, setPing] = useState(config.ping || "");
const [download, setDownload] = useState(config.download || "");
const [upload, setUpload] = useState(config.upload || "");
const [recommendations, setRecommendations] = useState(null);
useEffect(() => {
jsonRequest("/recommendations").then((result) => {
if (!result.message) setRecommendations(result);
}).catch(() => {});
}, []);
const applyRecommendations = () => {
if (recommendations) {
setPing(recommendations.ping.toString());
setDownload(recommendations.download.toString());
setUpload(recommendations.upload.toString());
}
};
const update = async () => {
if ((ping && /[^0-9.]/.test(ping)) || (download && /[^0-9.]/.test(download)) || (upload && /[^0-9.]/.test(upload))) {
updateToast(t("dropdown.invalid"), "red", faExclamationTriangle);
return;
}
try {
if (ping !== config.ping) await patchRequest("/config/ping", {value: ping});
if (download !== config.download) await patchRequest("/config/download", {value: download});
if (upload !== config.upload) await patchRequest("/config/upload", {value: upload});
reloadConfig();
updateToast(t("dropdown.changes_applied"), "green", faCheck);
close();
} catch (e) {
updateToast(t("dropdown.changes_unsaved"), "red", faExclamationTriangle);
}
};
return (
<>
<div className="dialog-header">
<h4 className="dialog-text">{t("optimal_values.title")}</h4>
<FontAwesomeIcon icon={faClose} className="dialog-text dialog-icon" onClick={() => close()}/>
</div>
<div className="optimal-values-content">
<div className="optimal-values-speeds">
<div className="optimal-values-speed">
<div className="optimal-values-speed-header">
<FontAwesomeIcon icon={faTableTennis}/>
<div className="optimal-values-speed-text">
<h2>{t("latest.ping")}</h2>
<p>{t("welcome.ms")}</p>
</div>
</div>
<input type="number" placeholder={recommendations?.ping || ""} className="dialog-input"
value={ping} onChange={(e) => setPing(e.target.value)}/>
</div>
<div className="optimal-values-speed">
<div className="optimal-values-speed-header">
<FontAwesomeIcon icon={faArrowDown}/>
<div className="optimal-values-speed-text">
<h2>{t("latest.down")}</h2>
<p>{t("welcome.mbps")}</p>
</div>
</div>
<input type="number" placeholder={recommendations?.download || ""} className="dialog-input"
value={download} onChange={(e) => setDownload(e.target.value)}/>
</div>
<div className="optimal-values-speed">
<div className="optimal-values-speed-header">
<FontAwesomeIcon icon={faArrowUp}/>
<div className="optimal-values-speed-text">
<h2>{t("latest.up")}</h2>
<p>{t("welcome.mbps")}</p>
</div>
</div>
<input type="number" placeholder={recommendations?.upload || ""} className="dialog-input"
value={upload} onChange={(e) => setUpload(e.target.value)}/>
</div>
</div>
</div>
<div className="dialog-buttons">
{recommendations && (
<button className="dialog-btn" onClick={applyRecommendations}>
<FontAwesomeIcon icon={faWandMagicSparkles}/>
<span>{t("optimal_values.use_recommended")}</span>
</button>
)}
<button className="dialog-btn" onClick={update}>{t("dialog.update")}</button>
</div>
</>
);
};
export const OptimalValuesDialog = (props) => {
return (
<DialogProvider close={props.onClose}>
<Dialog/>
</DialogProvider>
);
};
@@ -0,0 +1 @@
export {OptimalValuesDialog as default} from "./OptimalValuesDialog";
@@ -0,0 +1,53 @@
@use "@/common/styles/colors" as *
.optimal-values-content
user-select: none
margin: 0.5rem 0
.optimal-values-speeds
display: flex
justify-content: center
gap: 1.5rem
.optimal-values-speed
display: flex
flex-direction: column
align-items: center
input
box-sizing: border-box
width: 7rem
text-align: center
.optimal-values-speed-header
display: flex
align-items: center
margin-bottom: 0.5rem
svg
font-size: 1.5rem
margin-right: 0.4rem
color: $green
.optimal-values-speed-text h2
margin: 0
color: $subtext
font-size: 1rem
.optimal-values-speed-text p
margin: 0
color: $subtext
font-size: 0.85rem
@media screen and (max-width: 600px)
.optimal-values-content .optimal-values-speeds
flex-direction: column
gap: 1rem
.optimal-values-speed
width: 100%
flex-direction: row
justify-content: space-between
input
width: 40%
@@ -49,11 +49,12 @@
justify-content: space-between
.provider-input
width: 20rem
width: 16rem
box-sizing: border-box
margin-top: 0.5rem
margin-bottom: 0.5rem
font-size: 1.3rem
font-size: 0.9rem
text-align: left
h3
color: $subtext
@@ -63,6 +63,10 @@
margin: 0
color: $subtext
div
display: flex
gap: 0.5rem
.dialog-btn
padding: 0.4rem 1rem
@@ -0,0 +1,13 @@
import "./styles.sass";
export const ToggleSwitch = ({checked, onChange, disabled = false}) => (
<label className={`toggle-switch ${disabled ? "toggle-disabled" : ""}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
<span className="toggle-slider"/>
</label>
);
@@ -0,0 +1 @@
export {ToggleSwitch as default} from "./ToggleSwitch";
@@ -0,0 +1,47 @@
@use "@/common/styles/colors" as *
.toggle-switch
position: relative
display: inline-block
width: 2.5rem
height: 1.375rem
flex-shrink: 0
input
opacity: 0
width: 0
height: 0
.toggle-slider
position: absolute
cursor: pointer
top: 0
left: 0
right: 0
bottom: 0
background-color: $light-gray
border-radius: 0.75rem
transition: background-color 0.2s ease
&::before
content: ""
position: absolute
height: 1rem
width: 1rem
left: 0.1875rem
top: 50%
transform: translateY(-50%)
background-color: $white
border-radius: 50%
transition: transform 0.2s ease
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2)
.toggle-switch input:checked + .toggle-slider
background-color: $accent-primary
.toggle-switch input:checked + .toggle-slider::before
transform: translateY(-50%) translateX(1.125rem)
.toggle-disabled
opacity: 0.5
pointer-events: none
@@ -14,16 +14,17 @@
display: flex
justify-content: space-between
align-items: center
margin-top: 1rem
margin-top: 1.25rem
h3
margin: 0
font-size: 14pt
font-size: 13pt
color: $subtext
font-weight: 500
.dialog-btn
padding: 0.4rem 1.3rem
border-radius: 0.6rem
padding: 0.5rem 1.5rem
border-radius: 0.5rem
.slide-in
animation: slide-in 0.5s forwards
@@ -31,7 +32,7 @@
@keyframes slide-in
from
opacity: 0
transform: translateX(10%) rotate(10deg) scale(0.5)
transform: translateX(20px)
to
opacity: 1
transform: translateX(0)
+46 -29
View File
@@ -8,12 +8,12 @@
right: 0
width: 100%
height: 100%
background-color: rgba(0, 0, 0, 0.6)
background-color: rgba(0, 0, 0, 0.7)
display: flex
align-items: center
z-index: 999
justify-content: center
backdrop-filter: blur(2px)
backdrop-filter: blur(4px)
transition: all 0.2s
animation: opacity 0.3s
@@ -22,12 +22,15 @@
animation: opacity 0.3s reverse
.dialog
padding: 15px
padding: 1.25rem
background-color: $dark-gray
backdrop-filter: blur(4px)
-webkit-backdrop-filter: blur(4px)
border: 1px solid $light-gray
border-radius: 15px
border-radius: 1rem
transition: all 0.2s
animation: fadeIn 0.3s
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3)
.dialog-hidden
visibility: hidden
@@ -41,8 +44,12 @@
user-select: none
.dialog a
color: $green
color: $accent-primary
cursor: pointer
transition: color 0.15s ease
.dialog a:hover
color: $accent-secondary
.dialog-main
display: flex
@@ -52,50 +59,60 @@
.dialog-buttons
display: flex
margin-top: 5px
justify-content: right
margin-top: 1rem
justify-content: flex-end
gap: 0.5rem
.dialog-text
font-size: 16pt
font-size: 15pt
color: $subtext
margin: 0
.dialog-description
font-size: 16pt
margin: 10px 2px 2px
font-size: 15pt
margin: 12px 2px 2px
color: $subtext
line-height: 1.5
.dialog-description a
color: $green
color: $accent-primary
text-decoration: underline
.dialog-value
color: $green
color: $accent-primary
font-weight: 600
.dialog-icon
cursor: pointer
color: $subtext
transition: color 0.15s ease
.dialog-icon:hover
color: $red
color: $accent-danger
.dialog-btn
font-size: 16pt
padding: 8px 15px
border: none
font-weight: 700
border-radius: 5px
color: $dark-gray
background-color: $green
display: flex
align-items: center
justify-content: center
gap: 0.5rem
padding: 10px 16px
background-color: $darker-gray
border: 1px solid $light-gray
border-radius: 0.5rem
color: $white
font-size: 12pt
font-weight: 600
cursor: pointer
margin-left: 5px
margin-right: 5px
transition: all 0.2s
transition: all 0.15s ease
.dialog-btn:hover
filter: brightness(0.8)
&:hover
border-color: $accent-primary
color: $accent-primary
svg
font-size: 0.9rem
.dialog-secondary
background-color: $red
.dialog-secondary:hover
background-color: $red
&:hover
border-color: $accent-danger
color: $accent-danger
@@ -1,24 +1,32 @@
@use "@/common/styles/colors" as *
.input-dialog
width: 480px
width: 400px
.dialog-input
font-size: 18pt
padding: 15px
font-weight: 700
font-size: 0.9rem
padding: 0.6rem 0.875rem
font-weight: 500
margin-top: 15px
margin-bottom: 15px
width: 100%
background-color: $darker-gray
color: $subtext
color: $white
border: 1px solid $light-gray
border-radius: 15px
border-radius: 0.5rem
text-align: center
box-sizing: border-box
outline: none
transition: all 0.15s ease
.dialog-input::placeholder
color: $subtext
.dialog-input:focus
border: 1px solid $green
border-color: $accent-primary
.input-error
border: 1px solid $red
border-color: $accent-danger
.input-error:focus
border-color: $accent-danger
@@ -9,6 +9,8 @@ export const StatusProvider = (props) => {
const updateStatus = () => jsonRequest("/speedtests/status").then(status => setStatus(status));
const setRunning = (running) => setStatus(prev => ({...prev, running}));
useEffect(() => {
updateStatus();
const interval = setInterval(() => updateStatus(), 5000);
@@ -16,7 +18,7 @@ export const StatusProvider = (props) => {
}, []);
return (
<StatusContext.Provider value={[status, updateStatus]}>
<StatusContext.Provider value={[status, updateStatus, setRunning]}>
{props.children}
</StatusContext.Provider>
)
@@ -5,69 +5,77 @@
bottom: 2rem
right: 1rem
z-index: 5
background-color: $darker-gray
box-shadow: 0 0 1rem $darker-gray
border: 2px solid $light-gray
border-radius: 0.5rem
animation: 0.5s moveIn
background-color: var(--glass-bg)
backdrop-filter: blur(16px)
-webkit-backdrop-filter: blur(16px)
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25)
border: 1px solid $light-gray
border-radius: 0.75rem
animation: 0.5s moveIn cubic-bezier(0.4, 0, 0.2, 1)
cursor: pointer
transition: all 0.2s ease
.toast-hidden
visibility: hidden
transition: all 0s 0.5s
animation: 0.5s moveOut
animation: 0.5s moveOut cubic-bezier(0.4, 0, 0.2, 1)
.toast-green
transition: all 0.3s
border-left: 4px solid $accent-primary
& .toast-content svg
color: $green
color: $accent-primary
.toast-green:hover
border-color: $green
filter: brightness(0.9)
border-color: $light-gray
border-left-color: $accent-primary
transform: translateX(-4px)
.toast-red
border-left: 4px solid $accent-danger
& .toast-content svg
color: $red
color: $accent-danger
.toast-red:hover
border-color: $red
filter: brightness(0.9)
border-color: $light-gray
border-left-color: $accent-danger
transform: translateX(-4px)
.toast-content
display: flex
align-items: center
padding: 1rem 1rem
padding: 1rem 1.25rem
color: $white
font-size: 14px
font-weight: 500
.toast-content svg
margin-right: 1rem
width: 2rem
height: 2rem
width: 1.75rem
height: 1.75rem
.toast-content h2
margin: 0
font-size: 1.4rem
font-size: 1.25rem
font-weight: 600
@keyframes moveIn
0%
transform: translateX(100%)
60%
transform: translateX(-10%)
opacity: 0
100%
transform: translateX(0)
opacity: 1
@keyframes moveOut
0%
transform: translateX(0)
60%
transform: translateX(-10%)
opacity: 1
100%
transform: translateX(100%)
opacity: 0
@media screen and (max-width: 425px)
@@ -79,15 +87,15 @@
@keyframes moveIn
0%
transform: translateY(100%)
60%
transform: translateY(-10%)
opacity: 0
100%
transform: translateY(0)
opacity: 1
@keyframes moveOut
0%
transform: translateY(0)
60%
transform: translateY(-10%)
opacity: 1
100%
transform: translateY(100%)
transform: translateY(100%)
opacity: 0
+51 -17
View File
@@ -1,21 +1,26 @@
$dark-background: #232835
$dark-light-gray: #353A47
$dark-dark-gray: #1d2128
$dark-darker-gray: #20252F
$dark-white: #F1F1F1
$dark-subtext: #C8C8C8
$dark-background: #0f1419
$dark-light-gray: #2a3441
$dark-dark-gray: #1a2029
$dark-darker-gray: #151b23
$dark-white: #e7edf4
$dark-subtext: #8b99ab
$light-background: #F5F5F5
$light-light-gray: #E0E0E0
$light-dark-gray: #FFFFFF
$light-darker-gray: #F0F0F0
$light-white: #2C2C2C
$light-subtext: #666666
$light-background: #f8fafc
$light-light-gray: #e2e8f0
$light-dark-gray: #ffffff
$light-darker-gray: #f1f5f9
$light-white: #1e293b
$light-subtext: #64748b
$blue: #456AC6
$orange: #E58A00
$green: #45C65A
$red: #C64545
$blue: #3b82f6
$orange: #f59e0b
$green: #10b981
$red: #ef4444
$cyan: #06b6d4
$purple: #8b5cf6
$pink: #ec4899
$amber: #f59e0b
:root
--background: #{$dark-background}
@@ -24,6 +29,23 @@ $red: #C64545
--darker-gray: #{$dark-darker-gray}
--white: #{$dark-white}
--subtext: #{$dark-subtext}
--blue: #{$blue}
--accent-primary: #{$green}
--accent-secondary: #{$cyan}
--accent-tertiary: #{$purple}
--accent-warning: #{$orange}
--accent-danger: #{$red}
--glass-bg: rgba(26, 32, 41, 0.6)
--chart-download: #{$cyan}
--chart-upload: #{$purple}
--chart-ping: #{$amber}
--chart-average: #{$pink}
--chart-grid: rgba(42, 52, 65, 0.6)
--chart-tooltip-bg: #{$dark-darker-gray}
--chart-tooltip-border: #{$dark-light-gray}
[data-theme="light"]
--background: #{$light-background}
@@ -33,9 +55,21 @@ $red: #C64545
--white: #{$light-white}
--subtext: #{$light-subtext}
--glass-bg: rgba(255, 255, 255, 0.85)
--chart-grid: rgba(226, 232, 240, 0.8)
--chart-tooltip-bg: #{$light-dark-gray}
--chart-tooltip-border: #{$light-light-gray}
$background: var(--background)
$light-gray: var(--light-gray)
$dark-gray: var(--dark-gray)
$darker-gray: var(--darker-gray)
$white: var(--white)
$subtext: var(--subtext)
$subtext: var(--subtext)
$accent-primary: var(--accent-primary)
$accent-secondary: var(--accent-secondary)
$accent-tertiary: var(--accent-tertiary)
$accent-warning: var(--accent-warning)
$accent-danger: var(--accent-danger)
+33 -17
View File
@@ -6,10 +6,27 @@ body, html
overflow-x: hidden
background-color: $background
font-family: "Inter", sans-serif
font-weight: 700
font-weight: 600
position: relative
body::before
content: ""
position: fixed
top: -30%
left: 50%
transform: translateX(-50%)
width: 150%
height: 80%
background: radial-gradient(ellipse at center, rgba(16, 185, 129, 0.06) 0%, transparent 60%)
pointer-events: none
z-index: 0
#root
position: relative
z-index: 1
::-webkit-scrollbar
width: 14px
width: 12px
background: $background
::-webkit-scrollbar-track
@@ -20,15 +37,14 @@ body, html
::-webkit-scrollbar-thumb
background: $light-gray
border-radius: 10px
border: 2px solid $background
border: 3px solid $background
min-height: 40px
::-webkit-scrollbar-thumb:hover
background: $white
box-shadow: 0 0 5px rgba(0, 0, 0, 0.3)
background: $subtext
::-webkit-scrollbar-thumb:active
background: $subtext
background: $white
.speedtest-icon
font-size: 26pt
@@ -45,17 +61,17 @@ body, html
cursor: help
.icon-red
color: $red
color: $accent-danger
.icon-error
color: $red
filter: brightness(0.8)
color: $accent-danger
filter: brightness(0.9)
.icon-green
color: $green
color: $accent-primary
.icon-orange
color: $orange
color: $accent-warning
.icon-blue
color: $blue
@@ -76,20 +92,20 @@ main
@keyframes fadeIn
0%
opacity: 0
transform: scale(0.8) translateY(3rem)
filter: blur(3px)
transform: scale(0.95)
filter: blur(2px)
100%
opacity: 1
transform: scale(1) translateY(0)
transform: scale(1)
@keyframes fadeOut
0%
opacity: 1
transform: scale(1) translateY(0)
transform: scale(1)
100%
opacity: 0
transform: scale(0.4) translateY(4rem)
filter: blur(3px)
transform: scale(0.95)
filter: blur(2px)
@media (max-width: 730px)
::-webkit-scrollbar
@@ -1,4 +1,4 @@
import {useContext, useEffect, useState} from "react";
import {useContext, useEffect, useState, useRef, useMemo} from "react";
import {faArrowDown, faArrowUp, faClockRotateLeft, faPingPongPaddleBall} from "@fortawesome/free-solid-svg-icons";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {generateRelativeTime} from "./utils";
@@ -11,6 +11,52 @@ import {getIconBySpeed} from "@/common/utils/TestUtil";
import {downloadInfo, latestTestInfo, pingInfo, uploadInfo} from "@/pages/Home/components/LatestTest/utils/dialogs";
import {t} from "i18next";
const BORDER_RADIUS = 14;
const DASH_LENGTH = 200;
const STROKE_WIDTH = 3;
const BorderAnimation = () => {
const ref = useRef(null);
const [size, setSize] = useState({ w: 100, h: 100 });
useEffect(() => {
const parent = ref.current?.parentElement;
if (!parent) return;
const update = () => {
setSize({ w: parent.offsetWidth, h: parent.offsetHeight });
};
update();
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(parent);
return () => resizeObserver.disconnect();
}, []);
const perimeter = useMemo(() => {
const inner = { w: size.w - 2, h: size.h - 2 };
return 2 * (inner.w - 2 * BORDER_RADIUS) + 2 * (inner.h - 2 * BORDER_RADIUS) + 2 * Math.PI * BORDER_RADIUS;
}, [size]);
return (
<svg ref={ref} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 10 }}>
<rect x={1} y={1} width={size.w - 2} height={size.h - 2} rx={BORDER_RADIUS} ry={BORDER_RADIUS}
fill="none" stroke="var(--accent-primary)" strokeWidth={STROKE_WIDTH} strokeLinecap="round"
strokeDasharray={`${DASH_LENGTH} ${perimeter}`}
style={{ animation: `border-dash 3s linear infinite` }} />
<style>{`@keyframes border-dash { to { stroke-dashoffset: -${perimeter + DASH_LENGTH}; } }`}</style>
</svg>
);
};
const LoadingValue = ({ children }) => {
return (
<span className="loading-value">{children}</span>
);
};
const LatestTestComponent = () => {
const status = useContext(StatusContext)[0];
const [latest, setLatest] = useState(null);
@@ -32,9 +78,15 @@ const LatestTestComponent = () => {
if (Object.entries(config).length === 0) return (<></>);
if (latest === null) return (<></>);
const getAreaClass = () => {
if (status.running) return "analyse-area test-running";
if (status.paused) return "analyse-area tests-paused";
return "analyse-area pulse";
};
return (
<div className={"analyse-area " + (status.paused ? "tests-paused" : "pulse")}>
{/* Ping */}
<div className={getAreaClass()}>
{status.running && <BorderAnimation />}
<div className="inner-container">
<div className="container-header">
<FontAwesomeIcon onClick={() => setDialog(pingInfo())} icon={faPingPongPaddleBall}
@@ -43,11 +95,16 @@ const LatestTestComponent = () => {
className="container-subtext">{t("latest.ping_unit")}</span></h2>
</div>
<div className="container-main">
<h2>{latest.ping === -1 ? "N/A" : latest.ping}</h2>
<h2>
{status.running ? (
<LoadingValue>{latest.ping === -1 ? "N/A" : latest.ping}</LoadingValue>
) : (
latest.ping === -1 ? "N/A" : latest.ping
)}
</h2>
</div>
</div>
{/* Download */}
<div className="inner-container">
<div className="container-header">
<FontAwesomeIcon onClick={() => setDialog(downloadInfo())} icon={faArrowDown}
@@ -56,13 +113,18 @@ const LatestTestComponent = () => {
className="container-subtext">{t("latest.speed_unit")}</span></h2>
</div>
<div className="container-main">
<h2>{latest.download === -1 ? "N/A" : latest.download}</h2>
<h2>
{status.running ? (
<LoadingValue>{latest.download === -1 ? "N/A" : latest.download}</LoadingValue>
) : (
latest.download === -1 ? "N/A" : latest.download
)}
</h2>
</div>
</div>
<div className="mobile-break"></div>
{/* Upload */}
<div className="inner-container">
<div className="container-header">
<FontAwesomeIcon onClick={() => setDialog(uploadInfo())} icon={faArrowUp}
@@ -71,11 +133,16 @@ const LatestTestComponent = () => {
className="container-subtext">{t("latest.speed_unit")}</span></h2>
</div>
<div className="container-main">
<h2>{latest.upload === -1 ? "N/A" : latest.upload}</h2>
<h2>
{status.running ? (
<LoadingValue>{latest.upload === -1 ? "N/A" : latest.upload}</LoadingValue>
) : (
latest.upload === -1 ? "N/A" : latest.upload
)}
</h2>
</div>
</div>
{/* Latest update */}
<div className="inner-container">
<div className="container-header">
<FontAwesomeIcon onClick={() => setDialog(latestTestInfo(latest))} icon={faClockRotateLeft}
@@ -84,7 +151,13 @@ const LatestTestComponent = () => {
className="container-subtext">{t("latest.before")}</span></h2>
</div>
<div className="container-main">
<h2>{latestTestTime}</h2>
<h2>
{status.running ? (
<LoadingValue>{latestTestTime}</LoadingValue>
) : (
latestTestTime
)}
</h2>
</div>
</div>
</div>
@@ -1,55 +1,78 @@
@use "@/common/styles/colors" as *
$purple-primary: #8b5cf6
$purple-secondary: #7c3aed
$purple-glow: rgba(139, 92, 246, 0.4)
.analyse-area
margin-top: 2rem
display: flex
padding: 2.5rem 2rem
border: transparent 1px solid
background-color: $dark-gray
border-radius: 15px
border: 1px solid $light-gray
background-color: var(--glass-bg)
backdrop-filter: blur(16px)
-webkit-backdrop-filter: blur(16px)
border-radius: 1rem
width: 100%
transition: all 0.5s
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1)
align-items: center
justify-content: center
margin-bottom: 2rem
box-sizing: border-box
user-select: none
position: relative
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1)
.pulse
border: $green 2px solid
border-color: $accent-primary
animation: pulse 2s infinite
@keyframes pulse
0%
box-shadow: 0 0 0 0 #14AB37FF
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.6)
70%
box-shadow: 0 0 0 10px #CCA92C00
box-shadow: 0 0 0 12px rgba(16, 185, 129, 0)
100%
box-shadow: 0 0 0 0 #CCA92C00
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0)
.test-running
border: 1px solid $light-gray
animation: none
position: relative
.loading-value
display: inline-block
animation: value-pulse 1.5s ease-in-out infinite
@keyframes value-pulse
0%, 100%
opacity: 0.3
50%
opacity: 0.6
.inner-container
margin-left: 2rem
margin-right: 2rem
transition: all 0.5s
animation: fadeIn 0.5s
transition: margin 0.5s
animation: fadeIn 0.5s ease-out
.container-header
display: flex
align-items: center
.tests-paused
border: $orange 2px solid
border-color: $accent-warning
.container-text
margin: 0 0 0 25px
color: $white
font-weight: 700
font-weight: 600
font-size: 24pt
white-space: nowrap
.container-subtext
color: $subtext
font-size: 16pt
font-size: 15pt
margin-left: 10px
font-weight: 500
@@ -1,4 +1,4 @@
import React, {useContext, useRef} from "react";
import React, {forwardRef, useContext, useRef, useImperativeHandle} from "react";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {
faArrowDown, faArrowUp, faClockRotateLeft, faClose,
@@ -14,7 +14,7 @@ import {t} from "i18next";
import {ConfigContext} from "@/common/contexts/Config";
import {ToastNotificationContext} from "@/common/contexts/ToastNotification";
const SpeedtestComponent = (props) => {
const SpeedtestComponent = forwardRef((props, forwardedRef) => {
const [setDialog] = useContext(InputDialogContext);
const updateToast = useContext(ToastNotificationContext);
const [config] = useContext(ConfigContext);
@@ -22,6 +22,8 @@ const SpeedtestComponent = (props) => {
const ref = useRef();
useImperativeHandle(forwardedRef, () => ref.current);
let errorMessage = t("test.unknown_error") + " " + props.error;
let isAverage = props.type === "average";
@@ -86,6 +88,6 @@ const SpeedtestComponent = (props) => {
</div>
</div>
);
}
});
export default SpeedtestComponent;
@@ -4,20 +4,27 @@
margin-bottom: 2rem
display: flex
padding: 1.5rem 2rem
border: $light-gray 2px solid
background-color: $dark-gray
border-radius: 15px
border: 1px solid $light-gray
background-color: var(--glass-bg)
backdrop-filter: blur(16px)
-webkit-backdrop-filter: blur(16px)
border-radius: 1rem
justify-content: space-between
width: 100%
flex-wrap: wrap
transition: all 0.5s
animation: fadeIn 0.5s
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1)
animation: fadeIn 0.5s ease-out
cursor: pointer
user-select: none
box-sizing: border-box
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1)
position: relative
overflow: hidden
.speedtest:hover
border: $green 2px solid
border-color: var(--accent-primary)
transform: translateY(-4px)
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(16, 185, 129, 0.1)
.speedtest-hidden
visibility: hidden
@@ -146,24 +146,24 @@ const TestArea = () => {
const isLast = index === speedtests.length - 1;
return (
<div key={test.id} ref={isLast ? lastElementRef : null}>
<Speedtest
time={date}
ping={test.ping}
pingLevel={getIconBySpeed(test.ping, config.ping, false)}
down={test.download}
downLevel={getIconBySpeed(test.download, config.download, true)}
up={test.upload}
upLevel={getIconBySpeed(test.upload, config.upload, true)}
error={test.error}
url={test.url}
type={test.type}
duration={test.time}
amount={test.amount}
resultId={test.resultId}
id={test.id}
/>
</div>
<Speedtest
key={test.id}
ref={isLast ? lastElementRef : null}
time={date}
ping={test.ping}
pingLevel={getIconBySpeed(test.ping, config.ping, false)}
down={test.download}
downLevel={getIconBySpeed(test.download, config.download, true)}
up={test.upload}
upLevel={getIconBySpeed(test.upload, config.upload, true)}
error={test.error}
url={test.url}
type={test.type}
duration={test.time}
amount={test.amount}
resultId={test.resultId}
id={test.id}
/>
);
})}
@@ -57,7 +57,7 @@
.back-to-top-button
bottom: 20px
left: 20px
right: 20px
width: 48px
height: 48px
@@ -72,7 +72,7 @@
.back-to-top-button
position: fixed
bottom: 30px
left: 30px
right: 30px
z-index: 50
width: 56px
height: 56px
@@ -2,7 +2,9 @@
.node-item
border: 2px solid $light-gray
background-color: $dark-gray
background-color: var(--glass-bg)
backdrop-filter: blur(16px)
-webkit-backdrop-filter: blur(16px)
border-radius: 15px
display: flex
width: 50rem
+6 -1
View File
@@ -26,7 +26,9 @@
justify-content: center
align-items: center
border: 2px solid $light-gray
background-color: $dark-gray
background-color: var(--glass-bg)
backdrop-filter: blur(16px)
-webkit-backdrop-filter: blur(16px)
border-radius: 15px
cursor: pointer
user-select: none
@@ -51,6 +53,9 @@
.node-disabled
width: 53rem
border: 2px dashed $light-gray
background-color: var(--glass-bg)
backdrop-filter: blur(16px)
-webkit-backdrop-filter: blur(16px)
border-radius: 15px
cursor: not-allowed
user-select: none
+21
View File
@@ -0,0 +1,21 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faQuestion} from "@fortawesome/free-solid-svg-icons";
import {useTranslation} from "react-i18next";
import "./styles.sass";
export const NotFound = () => {
const {t} = useTranslation();
return (
<div className="not-found-page">
<div className="not-found-content">
<div className="not-found-icon">
<FontAwesomeIcon icon={faQuestion} size="4x"/>
</div>
<h1 className="not-found-title">404</h1>
<h2 className="not-found-subtitle">{t("not_found.title")}</h2>
<p className="not-found-description">{t("not_found.description")}</p>
</div>
</div>
);
};
+1
View File
@@ -0,0 +1 @@
export {NotFound as default} from "./NotFound";
+50
View File
@@ -0,0 +1,50 @@
@use "@/common/styles/_colors" as *
.not-found-page
display: flex
flex-direction: column
justify-content: center
align-items: center
min-height: calc(100vh - 5rem)
padding: 2rem
text-align: center
color: $white
.not-found-content
display: flex
flex-direction: column
align-items: center
gap: 1rem
max-width: 400px
.not-found-icon
width: 100px
height: 100px
border-radius: 50%
background: $dark-gray
display: flex
align-items: center
justify-content: center
margin-bottom: 1rem
svg
color: $orange
.not-found-title
font-size: 5rem
font-weight: 700
margin: 0
color: $white
line-height: 1
.not-found-subtitle
font-size: 1.5rem
font-weight: 600
margin: 0
color: $white
.not-found-description
font-size: 1rem
color: $subtext
margin: 0
line-height: 1.5
@@ -0,0 +1,45 @@
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faExclamationTriangle} from "@fortawesome/free-solid-svg-icons";
import {useNavigate, useRouteError, isRouteErrorResponse} from "react-router-dom";
import {useTranslation} from "react-i18next";
import "./styles.sass";
export const RouteError = () => {
const error = useRouteError();
const navigate = useNavigate();
const {t} = useTranslation();
const is404 = isRouteErrorResponse(error) && error.status === 404;
return (
<div className="route-error-page">
<div className="route-error-content">
<div className="route-error-icon">
<FontAwesomeIcon icon={faExclamationTriangle} size="4x"/>
</div>
<h1 className="route-error-title">
{is404 ? "404" : t("route_error.title")}
</h1>
<h2 className="route-error-subtitle">
{is404 ? t("not_found.title") : t("route_error.subtitle")}
</h2>
<p className="route-error-description">
{is404 ? t("not_found.description") : t("route_error.description")}
</p>
{!is404 && error?.message && (
<pre className="route-error-details">{error.message}</pre>
)}
<div className="route-error-actions">
<button className="dialog-btn" onClick={() => navigate("/")}>
{t("not_found.back_home")}
</button>
{!is404 && (
<button className="dialog-btn dialog-secondary" onClick={() => window.location.reload()}>
{t("route_error.reload")}
</button>
)}
</div>
</div>
</div>
);
};
+1
View File
@@ -0,0 +1 @@
export {RouteError as default} from "./RouteError";
+70
View File
@@ -0,0 +1,70 @@
@use "@/common/styles/_colors" as *
@use "@/common/contexts/Dialog/styles"
.route-error-page
display: flex
flex-direction: column
justify-content: center
align-items: center
min-height: calc(100vh - 5rem)
padding: 2rem
text-align: center
color: $white
.route-error-content
display: flex
flex-direction: column
align-items: center
gap: 1rem
max-width: 500px
.route-error-icon
width: 100px
height: 100px
border-radius: 50%
background: $dark-gray
display: flex
align-items: center
justify-content: center
margin-bottom: 1rem
svg
color: $red
.route-error-title
font-size: 3rem
font-weight: 700
margin: 0
color: $white
line-height: 1
.route-error-subtitle
font-size: 1.5rem
font-weight: 600
margin: 0
color: $white
.route-error-description
font-size: 1rem
color: $subtext
margin: 0.5rem 0 1rem 0
line-height: 1.5
.route-error-details
background: $darker-gray
border: 1px solid $light-gray
border-radius: 0.5rem
padding: 1rem
font-size: 0.875rem
color: $red
max-width: 100%
overflow-x: auto
margin: 0.5rem 0 1rem 0
white-space: pre-wrap
word-break: break-word
.route-error-actions
display: flex
gap: 1rem
flex-wrap: wrap
justify-content: center
+128 -20
View File
@@ -8,36 +8,95 @@ import {
PointElement,
RadialLinearScale,
Title,
Tooltip
Tooltip,
Filler
} from "chart.js";
import {useEffect, useState} from "react";
import Failed from "@/pages/Statistics/charts/FailedChart";
import {useEffect, useState, useCallback} from "react";
import {jsonRequest} from "@/common/utils/RequestUtil";
import DateRangePicker from "@/common/components/DateRangePicker";
import ChartModal from "@/common/components/ChartModal";
import SpeedChart from "@/pages/Statistics/charts/SpeedChart";
import LatestTestChart from "@/pages/Statistics/charts/LatestTestChart";
import PingChart from "@/pages/Statistics/charts/PingChart";
import DurationChart from "@/pages/Statistics/charts/DurationChart";
import OverviewChart from "@/pages/Statistics/charts/OverviewChart";
import ManualChart from "@/pages/Statistics/charts/ManualChart";
import AverageChart from "@/pages/Statistics/charts/AverageChart";
import HourlyChart from "@/pages/Statistics/charts/HourlyChart.jsx";
import ConsistencyChart from "@/pages/Statistics/charts/ConsistencyChart";
import i18n, {t} from "i18next";
import "./styles.sass";
ChartJS.register(ArcElement, Tooltip, CategoryScale, LinearScale, PointElement, LineElement, Title, Legend, BarElement, RadialLinearScale);
ChartJS.defaults.color = "#B0B0B0";
ChartJS.defaults.font.color = "#B0B0B0";
ChartJS.register(ArcElement, Tooltip, CategoryScale, LinearScale, PointElement, LineElement, Title, Legend, BarElement, RadialLinearScale, Filler);
const crosshairPlugin = {
id: 'crosshair',
afterDraw: (chart) => {
if (chart.tooltip?._active?.length) {
const ctx = chart.ctx;
const activePoint = chart.tooltip._active[0];
const x = activePoint.element.x;
const topY = chart.scales.y.top;
const bottomY = chart.scales.y.bottom;
ctx.save();
ctx.beginPath();
ctx.setLineDash([5, 5]);
ctx.moveTo(x, topY);
ctx.lineTo(x, bottomY);
ctx.lineWidth = 1;
ctx.strokeStyle = 'hsla(215, 20%, 65%, 0.6)';
ctx.stroke();
ctx.restore();
}
}
};
ChartJS.register(crosshairPlugin);
ChartJS.defaults.color = "hsl(215, 20%, 55%)";
ChartJS.defaults.font.color = "hsl(215, 20%, 55%)";
ChartJS.defaults.font.family = "Inter, sans-serif";
ChartJS.defaults.font.weight = 500;
ChartJS.defaults.font.size = 11;
ChartJS.defaults.elements.line.tension = 0.35;
ChartJS.defaults.elements.line.borderWidth = 2.5;
ChartJS.defaults.elements.point.radius = 0;
ChartJS.defaults.elements.point.hoverRadius = 5;
ChartJS.defaults.elements.point.hoverBorderWidth = 2;
ChartJS.defaults.elements.arc.borderWidth = 0;
ChartJS.defaults.plugins.legend.labels.usePointStyle = true;
ChartJS.defaults.plugins.legend.labels.pointStyle = 'circle';
ChartJS.defaults.plugins.legend.labels.padding = 16;
ChartJS.defaults.plugins.legend.labels.boxWidth = 8;
ChartJS.defaults.plugins.legend.labels.boxHeight = 8;
export const Statistics = () => {
const [statistics, setStatistics] = useState(null);
const [latestTest, setLatestTest] = useState(null);
const [loading, setLoading] = useState(true);
const [expandedChart, setExpandedChart] = useState(null);
const updateStats = () => {
const [dateRange, setDateRange] = useState(() => {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 7);
return { from, to };
});
const formatDateParam = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const updateStats = useCallback(() => {
setLoading(true);
const fromParam = formatDateParam(dateRange.from);
const toParam = formatDateParam(dateRange.to);
return Promise.all([
jsonRequest("/speedtests/statistics/?days=7"),
jsonRequest(`/speedtests/statistics/?from=${fromParam}&to=${toParam}`),
jsonRequest("/speedtests?limit=1")
])
.then(([stats, tests]) => {
@@ -49,11 +108,15 @@ export const Statistics = () => {
console.error("Failed to load statistics:", error);
setLoading(false);
});
}, [dateRange]);
const handleDateRangeChange = (from, to) => {
setDateRange({ from, to });
};
useEffect(() => {
updateStats();
}, []);
}, [updateStats]);
useEffect(() => {
const callback = () => updateStats();
@@ -65,21 +128,66 @@ export const Statistics = () => {
if (!statistics) return <></>;
if (!statistics.tests || statistics.tests.length === 0) return <h2 className="error-text">{t("test.not_available")}</h2>;
const renderChart = (chartType) => {
switch (chartType) {
case 'overview':
return <OverviewChart tests={statistics.tests} time={statistics.time} dateRange={dateRange}/>;
case 'latest':
return <LatestTestChart test={latestTest}/>;
case 'consistency':
return <ConsistencyChart consistency={statistics.consistency}/>;
case 'download':
return <SpeedChart labels={statistics.labels} data={statistics.data} dataKey="download" titleKey="latest.down" color="hsl(187, 94%, 43%)" />;
case 'upload':
return <SpeedChart labels={statistics.labels} data={statistics.data} dataKey="upload" titleKey="latest.up" color="hsl(258, 90%, 66%)" />;
case 'ping':
return <PingChart labels={statistics.labels} data={statistics.data}/>;
case 'hourly':
return <HourlyChart hourlyAverages={statistics.hourlyAverages}/>;
case 'avgDownload':
return <AverageChart title={t("statistics.values.down")} data={statistics.download}/>;
case 'avgUpload':
return <AverageChart title={t("statistics.values.up")} data={statistics.upload}/>;
default:
return null;
}
};
return (
<div className="statistic-area">
<OverviewChart tests={statistics.tests} time={statistics.time}/>
<LatestTestChart test={latestTest}/>
<Failed tests={statistics.tests}/>
<div className="statistics-header">
<DateRangePicker
from={dateRange.from}
to={dateRange.to}
onChange={handleDateRangeChange}
/>
{statistics.aggregated && (
<span className="aggregation-info">
{t(`statistics.aggregation.${statistics.aggregationType}`)}
</span>
)}
</div>
<SpeedChart labels={statistics.labels} data={statistics.data}/>
<OverviewChart tests={statistics.tests} time={statistics.time} dateRange={dateRange} onClick={() => setExpandedChart('overview')}/>
<LatestTestChart test={latestTest} onClick={() => setExpandedChart('latest')}/>
<ConsistencyChart consistency={statistics.consistency} onClick={() => setExpandedChart('consistency')}/>
<ManualChart tests={statistics.tests}/>
<SpeedChart labels={statistics.labels} data={statistics.data} dataKey="download" titleKey="latest.down" color="hsl(187, 94%, 43%)" onClick={() => setExpandedChart('download')}/>
<SpeedChart labels={statistics.labels} data={statistics.data} dataKey="upload" titleKey="latest.up" color="hsl(258, 90%, 66%)" onClick={() => setExpandedChart('upload')}/>
<PingChart labels={statistics.labels} data={statistics.data} onClick={() => setExpandedChart('ping')}/>
<DurationChart time={statistics.data?.time}/>
<PingChart labels={statistics.labels} data={statistics.data}/>
<HourlyChart hourlyAverages={statistics.hourlyAverages} onClick={() => setExpandedChart('hourly')}/>
<AverageChart title={t("statistics.values.down")} data={statistics.download}/>
<AverageChart title={t("statistics.values.up")} data={statistics.upload}/>
<AverageChart title={t("statistics.values.down")} data={statistics.download} onClick={() => setExpandedChart('avgDownload')}/>
<AverageChart title={t("statistics.values.up")} data={statistics.upload} onClick={() => setExpandedChart('avgUpload')}/>
<ChartModal
isOpen={!!expandedChart}
onClose={() => setExpandedChart(null)}
isChart={['download', 'upload', 'ping', 'hourly'].includes(expandedChart)}
>
{expandedChart && renderChart(expandedChart)}
</ChartModal>
</div>
);
}
@@ -7,7 +7,7 @@ import "./styles.sass";
export const AverageChart = (props) => {
return (
<StatisticContainer title={props.title} size="small" center={true}>
<StatisticContainer title={props.title} size="small" center={true} onClick={props.onClick}>
<div className="value-container">
<div className="value-item">
<div className="value-info">
@@ -3,25 +3,28 @@
.value-container
display: flex
flex-direction: column
gap: 2rem
gap: 1.75rem
justify-content: center
width: 100%
.value-item
display: flex
justify-content: space-between
align-items: center
.value-item svg
width: 2.5rem
height: 2.5rem
color: $green
width: 2.25rem
height: 2.25rem
color: $accent-primary
.value-item .value-info h2
font-size: 16pt
font-size: 15pt
color: $white
margin: 0
font-weight: 600
.value-item .value-info p
color: $green
color: $accent-primary
margin: 0
font-weight: 600
font-weight: 600
font-size: 1.1rem
@@ -0,0 +1,60 @@
import { useMemo } from "react";
import { t } from "i18next";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowDown, faArrowUp, faPingPongPaddleBall } from "@fortawesome/free-solid-svg-icons";
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import "./styles.sass";
export const ConsistencyChart = (props) => {
const data = useMemo(() => {
if (!props.consistency) return null;
return props.consistency;
}, [props.consistency]);
if (!data) return null;
const getConsistencyColor = (value) => {
if (value >= 90) return 'icon-green';
if (value >= 70) return 'icon-orange';
return 'icon-red';
};
return (
<StatisticContainer title={t("statistics.consistency.title")} onClick={props.onClick}>
<div className="consistency-container">
<div className="consistency-item">
<div className="consistency-info">
<h2>{t("latest.down")}</h2>
<p className={getConsistencyColor(data.download.consistency)}>
{data.download.consistency}%
</p>
<span className="consistency-detail">±{data.download.stdDev} {t("latest.speed_unit")}</span>
</div>
<FontAwesomeIcon icon={faArrowDown} className={getConsistencyColor(data.download.consistency)} />
</div>
<div className="consistency-item">
<div className="consistency-info">
<h2>{t("latest.up")}</h2>
<p className={getConsistencyColor(data.upload.consistency)}>
{data.upload.consistency}%
</p>
<span className="consistency-detail">±{data.upload.stdDev} {t("latest.speed_unit")}</span>
</div>
<FontAwesomeIcon icon={faArrowUp} className={getConsistencyColor(data.upload.consistency)} />
</div>
<div className="consistency-item">
<div className="consistency-info">
<h2>{t("latest.ping")}</h2>
<p className="icon-orange">
±{data.ping.stdDev} {t("latest.ping_unit")}
</p>
<span className="consistency-detail">{t("statistics.consistency.ping_variance")}</span>
</div>
<FontAwesomeIcon icon={faPingPongPaddleBall} className="icon-orange" />
</div>
</div>
</StatisticContainer>
);
};
@@ -0,0 +1 @@
export { ConsistencyChart as default } from "./ConsistencyChart";
@@ -0,0 +1,35 @@
@use "@/common/styles/colors" as *
.consistency-container
display: flex
flex-direction: column
gap: 1.75rem
justify-content: center
width: 100%
.consistency-item
display: flex
justify-content: space-between
align-items: center
.consistency-item svg
width: 2.25rem
height: 2.25rem
.consistency-item .consistency-info h2
color: $white
margin: 0
font-size: 1rem
font-weight: 600
.consistency-item .consistency-info p
margin: 0
font-weight: 600
font-size: 1.1rem
.consistency-item .consistency-info .consistency-detail
color: $subtext
font-size: 0.8rem
font-weight: 500
display: block
margin-top: 0.125rem
@@ -1,68 +0,0 @@
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {Bar} from "react-chartjs-2";
import {useEffect, useState} from "react";
import {t} from "i18next";
const chartOptions = {
plugins: {
legend: {
display: false
}
},
scales: {
x: {
ticks: {
color: "#B0B0B0",
}
},
y: {
ticks: {
color: "#B0B0B0",
},
beginAtZero: true,
}
}
}
const DurationChart = (props) => {
const chartData = {
labels: [],
datasets: [{
label: t("statistics.duration.label"),
data: [],
backgroundColor: ['#45C65A', '#456AC6', '#C64545', '#C6C645', '#C645C6'],
borderWidth: 0,
}],
};
const [data, setData] = useState({});
useEffect(() => {
if (!props.time) return;
const frequencies = {};
const tempData = {...chartData};
props.time.forEach(second => {
frequencies[second] ? frequencies[second]++ : frequencies[second] = 1;
});
for (let second in frequencies) {
tempData.labels.push(t("time.seconds", {replace: {seconds: second.toString()}}));
tempData.datasets[0].data.push(frequencies[second]);
}
setData(tempData);
}, [props.time]);
if (Object.keys(data).length === 0) return <></>;
return (
<StatisticContainer title={t("statistics.duration.title")} center={true}>
<Bar data={data} options={chartOptions}/>
</StatisticContainer>
);
}
export default DurationChart;
@@ -1,35 +0,0 @@
import {Doughnut} from "react-chartjs-2";
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {t} from "i18next";
const chartOptions = {
plugins: {
legend: {
display: false
}
},
responsive: true,
cutout: 80
};
const FailedChart = (props) => {
const chartData = {
labels: [t("statistics.failed.success"), t("statistics.failed.failed")],
datasets: [{
label: t("statistics.failed.label"),
data: [props.tests.total - props.tests.failed, props.tests.failed],
backgroundColor: ['#456AC6', '#C64545'],
borderWidth: 0
}]
};
return (
<StatisticContainer title={t("statistics.failed.title")} center={true}>
<Doughnut data={chartData} options={chartOptions}/>
</StatisticContainer>
);
}
export default FailedChart;
@@ -0,0 +1,127 @@
import { Bar } from "react-chartjs-2";
import { useMemo, useContext } from "react";
import { t } from "i18next";
import { ThemeContext } from "@/common/contexts/Theme";
import "./SpeedChart/styles.sass";
const HourlyChart = (props) => {
const [isDarkMode] = useContext(ThemeContext);
const chartData = useMemo(() => {
if (!props.hourlyAverages) return { labels: [], datasets: [] };
const labels = props.hourlyAverages.map(h => {
const hour = h.hour;
return `${hour.toString().padStart(2, '0')}:00`;
});
return {
labels,
datasets: [
{
label: t("latest.down"),
data: props.hourlyAverages.map(h => h.download),
backgroundColor: 'hsla(187, 94%, 43%, 0.75)',
borderColor: 'hsl(187, 94%, 43%)',
borderWidth: 1.5,
borderRadius: 6
},
{
label: t("latest.up"),
data: props.hourlyAverages.map(h => h.upload),
backgroundColor: 'hsla(258, 90%, 66%, 0.75)',
borderColor: 'hsl(258, 90%, 66%)',
borderWidth: 1.5,
borderRadius: 6
}
]
};
}, [props.hourlyAverages]);
const gridColor = isDarkMode ? 'rgba(42, 52, 65, 0.6)' : 'rgba(203, 213, 225, 0.8)';
const tickColor = isDarkMode ? 'hsl(215, 20%, 50%)' : 'hsl(215, 25%, 40%)';
const tooltipBg = isDarkMode ? 'hsl(215, 28%, 10%)' : 'hsl(0, 0%, 100%)';
const tooltipTitle = isDarkMode ? 'hsl(210, 40%, 96%)' : 'hsl(215, 25%, 20%)';
const tooltipBody = isDarkMode ? 'hsl(215, 20%, 65%)' : 'hsl(215, 15%, 40%)';
const tooltipBorder = isDarkMode ? 'hsl(215, 25%, 22%)' : 'hsl(215, 20%, 85%)';
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
backgroundColor: tooltipBg,
titleColor: tooltipTitle,
bodyColor: tooltipBody,
borderColor: tooltipBorder,
borderWidth: 1,
padding: 14,
cornerRadius: 10,
displayColors: true,
boxPadding: 8,
callbacks: {
label: (item) => `${item.dataset.label}: ${item.formattedValue} ${t("latest.speed_unit")}`,
afterBody: (items) => {
const hourIndex = items[0].dataIndex;
const count = props.hourlyAverages[hourIndex]?.count || 0;
return `\n${t("statistics.hourly.sample_count")}: ${count}`;
}
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: 'rect',
padding: 20,
color: tickColor,
font: {
size: 12,
weight: 500
}
}
}
},
scales: {
x: {
grid: {
color: gridColor,
drawBorder: false
},
border: {
display: false
},
ticks: {
color: tickColor,
maxRotation: 0
}
},
y: {
beginAtZero: true,
grid: {
color: gridColor,
drawBorder: false
},
border: {
display: false
},
ticks: {
color: tickColor
}
}
}
};
return (
<div className="chart-container" onClick={props.onClick}>
<div className="chart-header">
<h3 className="chart-title">{t("statistics.hourly.title")}</h3>
</div>
<div className="chart-body">
<Bar data={chartData} options={chartOptions} />
</div>
</div>
);
};
export default HourlyChart;
@@ -15,7 +15,7 @@ export const LatestTestChart = (props) => {
if (config === null) return <></>;
return (
<StatisticContainer title={t("latest.latest")}>
<StatisticContainer title={t("latest.latest")} onClick={props.onClick}>
<div className="info-container">
<div className="test-container">
<div className="test-info">
@@ -3,22 +3,26 @@
.info-container
display: flex
flex-direction: column
gap: 2rem
gap: 1.75rem
justify-content: center
width: 100%
.test-container
display: flex
justify-content: space-between
align-items: center
.test-container svg
width: 3rem
height: 3rem
width: 2.5rem
height: 2.5rem
.test-container .test-info h2
color: $white
margin: 0
font-size: 1rem
font-weight: 600
.test-container .test-info p
margin: 0
font-weight: 600
font-weight: 600
font-size: 1.1rem
@@ -1,34 +0,0 @@
import {Doughnut} from "react-chartjs-2";
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {t} from "i18next";
const chartOptions = {
plugins: {
legend: {
display: false
}
},
cutout: 80
};
const ManuelChart = (props) => {
const chartData = {
labels: [t("statistics.manual.yes"), t("statistics.manual.no")],
datasets: [{
label: t("statistics.failed.label"),
data: [props.tests.custom, props.tests.total - props.tests.custom],
backgroundColor: ['#456AC6', '#45C65A'],
borderWidth: 0
}]
};
return (
<StatisticContainer title={t("statistics.manual.title")} center={true} size="small">
<Doughnut data={chartData} options={chartOptions}/>
</StatisticContainer>
);
}
export default ManuelChart;
@@ -5,8 +5,14 @@ import {faCircleExclamation, faGaugeHigh, faStopwatch} from "@fortawesome/free-s
import "./styles.sass";
export const OverviewChart = (props) => {
const formatDateForTitle = (date) => {
return date.toLocaleDateString(undefined, { day: "2-digit", month: "short" });
};
const title = t("test.overview.title", {replace: {amount: t("test.overview." + (localStorage.getItem("testTime") || 1))}});
const title = t("test.overview.title_range", {
from: formatDateForTitle(props.dateRange.from),
to: formatDateForTitle(props.dateRange.to)
});
const items = [
{
@@ -30,7 +36,7 @@ export const OverviewChart = (props) => {
];
return (
<StatisticContainer title={title} size="large">
<StatisticContainer title={title} size="large" onClick={props.onClick}>
<div className="overview-items">
{items.map((item, index) => (
<div className="overview-item" key={index}>
@@ -19,21 +19,25 @@
gap: 1rem
& svg
width: 2.5rem
height: 2.5rem
color: $white
width: 2.25rem
height: 2.25rem
color: $subtext
.overview-item .info-area .text-area
& h2
margin: 0
color: $white
font-size: 1rem
font-weight: 600
& p
margin: 0
font-weight: 500
color: $subtext
font-size: 0.85rem
.overview-item h2
color: $green
color: $accent-primary
font-weight: 700
@media screen and (max-width: 1500px)
.overview-item
+152 -27
View File
@@ -1,42 +1,167 @@
import {Line} from "react-chartjs-2";
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {t} from "i18next";
import { Line } from "react-chartjs-2";
import { useMemo, useContext } from "react";
import { t } from "i18next";
import { ThemeContext } from "@/common/contexts/Theme";
import "./SpeedChart/styles.sass";
const chartOptions = {
plugins: {
tooltip: {
callbacks: {
label: (item) => item.dataset.label + ": " + item.formattedValue + " " + t("latest.ping_unit")
const PingChart = (props) => {
const [isDarkMode] = useContext(ThemeContext);
const filteredData = useMemo(() => {
if (!props.data?.ping || !props.labels) return { labels: [], data: [], average: 0 };
const filtered = props.labels.map((label, index) => ({
label,
value: props.data.ping[index],
date: new Date(label)
})).filter(item => item.value !== null && item.value !== undefined);
const validValues = filtered.filter(item => item.value > 0).map(item => item.value);
const average = validValues.length > 0
? Math.round((validValues.reduce((a, b) => a + b, 0) / validValues.length) * 100) / 100
: 0;
return {
labels: filtered.map(item => item.label),
data: filtered.map(item => item.value),
average
};
}, [props.labels, props.data]);
const gridColor = isDarkMode ? 'rgba(42, 52, 65, 0.6)' : 'rgba(203, 213, 225, 0.8)';
const tickColor = isDarkMode ? 'hsl(215, 20%, 50%)' : 'hsl(215, 25%, 40%)';
const tooltipBg = isDarkMode ? 'hsl(215, 28%, 10%)' : 'hsl(0, 0%, 100%)';
const tooltipTitle = isDarkMode ? 'hsl(210, 40%, 96%)' : 'hsl(215, 25%, 20%)';
const tooltipBody = isDarkMode ? 'hsl(215, 20%, 65%)' : 'hsl(215, 15%, 40%)';
const tooltipBorder = isDarkMode ? 'hsl(215, 25%, 22%)' : 'hsl(215, 20%, 85%)';
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
backgroundColor: tooltipBg,
titleColor: tooltipTitle,
bodyColor: tooltipBody,
borderColor: tooltipBorder,
borderWidth: 1,
padding: 14,
cornerRadius: 10,
displayColors: true,
boxPadding: 8,
callbacks: {
label: (item) => `${item.dataset.label}: ${item.formattedValue} ${t("latest.ping_unit")}`
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: 'circle',
padding: 20,
color: tickColor,
font: {
size: 12,
weight: 500
}
}
}
},
legend: {
display: false
scales: {
x: {
reverse: false,
grid: {
color: gridColor,
drawBorder: false
},
border: {
display: false
},
ticks: {
color: tickColor,
maxTicksLimit: 5,
callback: function(value, index) {
const date = new Date(filteredData.labels[index]);
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
}
},
y: {
beginAtZero: true,
grid: {
color: gridColor,
drawBorder: false
},
border: {
display: false
},
ticks: {
color: tickColor
}
}
},
interaction: {
intersect: false,
mode: 'index'
},
elements: {
line: {
tension: 0.35,
borderWidth: 2.5
},
point: {
radius: 3,
hoverRadius: 6,
hoverBorderWidth: 2
}
}
},
scales: {x: {reverse: true}},
responsive: true
};
};
const SpeedChart = (props) => {
const testTime = localStorage.getItem("testTime") || 1;
const chartData = {
labels: testTime < 3 ? props.labels.map((label) => new Date(label).toLocaleTimeString([],
{hour: "2-digit", minute: "2-digit"})) : props.labels.slice(1).map((label) =>
new Date(label).toLocaleDateString()),
labels: filteredData.labels,
datasets: [
{
label: t("latest.ping"),
data: testTime < 3 ? props.data.ping : props.data.ping.slice(1),
borderColor: '#45C65A',
data: filteredData.data,
borderColor: 'hsl(38, 92%, 50%)',
backgroundColor: (context) => {
const ctx = context.chart.ctx;
const gradient = ctx.createLinearGradient(0, 0, 0, context.chart.height);
gradient.addColorStop(0, 'hsla(38, 92%, 50%, 0.25)');
gradient.addColorStop(1, 'hsla(38, 92%, 50%, 0.01)');
return gradient;
},
fill: true,
pointBackgroundColor: 'hsl(38, 92%, 50%)',
pointBorderColor: 'hsl(38, 92%, 50%)',
order: 1
},
{
label: t("statistics.average"),
data: filteredData.labels.map(() => filteredData.average),
borderColor: 'hsl(330, 80%, 60%)',
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [6, 4],
pointRadius: 0,
pointHoverRadius: 0,
fill: false,
order: 2
}
],
};
return (
<StatisticContainer title={t("latest.ping")} size="normal" center={true}>
<Line data={chartData} options={chartOptions}/>
</StatisticContainer>
)
<div className="chart-container ping-chart" onClick={props.onClick}>
<div className="chart-header">
<h3 className="chart-title">{t("latest.ping")} ({t("latest.ping_unit")})</h3>
</div>
<div className="chart-body">
<Line data={chartData} options={chartOptions} />
</div>
</div>
);
};
}
export default SpeedChart;
export default PingChart;
@@ -1,46 +0,0 @@
import {Line} from "react-chartjs-2";
import StatisticContainer from "@/pages/Statistics/components/StatisticContainer";
import {t} from "i18next";
const chartOptions = {
plugins: {
tooltip: {
callbacks: {
label: (item) => item.dataset.label + ": " + item.formattedValue + " " + t("latest.speed_unit")
}
},
legend: {
position: "bottom"
}
},
scales: {x: {reverse: true}}
};
const SpeedChart = (props) => {
const testTime = localStorage.getItem("testTime") || 1;
const chartData = {
labels: testTime < 3 ? props.labels.map((label) => new Date(label).toLocaleTimeString([],
{hour: "2-digit", minute: "2-digit"})) : props.labels.slice(1).map((label) =>
new Date(label).toLocaleDateString()),
datasets: [
{
label: t("latest.down"),
data: testTime < 3 ? props.data.download : props.data.download.slice(1),
borderColor: '#45C65A'
},
{
label: t("latest.up"),
data: testTime < 3 ? props.data.upload : props.data.upload.slice(1),
borderColor: '#456AC6'
},
],
};
return (
<StatisticContainer title={t("statistics.speed.title")} size="normal" center={true}>
<Line data={chartData} options={chartOptions}/>
</StatisticContainer>
)
}
export default SpeedChart;
@@ -0,0 +1,166 @@
import { Line } from "react-chartjs-2";
import { useMemo, useContext } from "react";
import { t } from "i18next";
import { ThemeContext } from "@/common/contexts/Theme";
import "./styles.sass";
export const SpeedChart = ({ labels, data, dataKey, titleKey, color, onClick }) => {
const [isDarkMode] = useContext(ThemeContext);
const filteredData = useMemo(() => {
if (!data?.[dataKey] || !labels) return { labels: [], data: [], average: 0 };
const filtered = labels.map((label, index) => ({
label,
value: data[dataKey][index],
date: new Date(label)
})).filter(item => item.value !== null && item.value !== undefined);
const validValues = filtered.filter(item => item.value > 0).map(item => item.value);
const average = validValues.length > 0
? Math.round((validValues.reduce((a, b) => a + b, 0) / validValues.length) * 100) / 100
: 0;
return {
labels: filtered.map(item => item.label),
data: filtered.map(item => item.value),
average
};
}, [labels, data, dataKey]);
const gridColor = isDarkMode ? 'rgba(42, 52, 65, 0.6)' : 'rgba(203, 213, 225, 0.8)';
const tickColor = isDarkMode ? 'hsl(215, 20%, 50%)' : 'hsl(215, 25%, 40%)';
const tooltipBg = isDarkMode ? 'hsl(215, 28%, 10%)' : 'hsl(0, 0%, 100%)';
const tooltipTitle = isDarkMode ? 'hsl(210, 40%, 96%)' : 'hsl(215, 25%, 20%)';
const tooltipBody = isDarkMode ? 'hsl(215, 20%, 65%)' : 'hsl(215, 15%, 40%)';
const tooltipBorder = isDarkMode ? 'hsl(215, 25%, 22%)' : 'hsl(215, 20%, 85%)';
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
backgroundColor: tooltipBg,
titleColor: tooltipTitle,
bodyColor: tooltipBody,
borderColor: tooltipBorder,
borderWidth: 1,
padding: 14,
cornerRadius: 10,
displayColors: true,
boxPadding: 8,
callbacks: {
label: (item) => `${item.dataset.label}: ${item.formattedValue} ${t("latest.speed_unit")}`
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: 'circle',
padding: 20,
color: tickColor,
font: {
size: 12,
weight: 500
}
}
}
},
scales: {
x: {
reverse: false,
grid: {
color: gridColor,
drawBorder: false
},
border: {
display: false
},
ticks: {
color: tickColor,
maxTicksLimit: 5,
callback: function(value, index) {
const date = new Date(filteredData.labels[index]);
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
}
},
y: {
beginAtZero: true,
grid: {
color: gridColor,
drawBorder: false
},
border: {
display: false
},
ticks: {
color: tickColor,
stepSize: 100
}
}
},
interaction: {
intersect: false,
mode: 'index'
},
elements: {
line: {
tension: 0.35,
borderWidth: 2.5
},
point: {
radius: 3,
hoverRadius: 6,
hoverBorderWidth: 2
}
}
};
const chartData = {
labels: filteredData.labels,
datasets: [
{
label: t(titleKey),
data: filteredData.data,
borderColor: color,
backgroundColor: (context) => {
const ctx = context.chart.ctx;
const gradient = ctx.createLinearGradient(0, 0, 0, context.chart.height);
gradient.addColorStop(0, color.replace('hsl', 'hsla').replace(')', ', 0.25)'));
gradient.addColorStop(1, color.replace('hsl', 'hsla').replace(')', ', 0.01)'));
return gradient;
},
fill: true,
pointBackgroundColor: color,
pointBorderColor: color,
order: 1
},
{
label: t("statistics.average"),
data: filteredData.labels.map(() => filteredData.average),
borderColor: 'hsl(330, 80%, 60%)',
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [6, 4],
pointRadius: 0,
pointHoverRadius: 0,
fill: false,
order: 2
}
],
};
return (
<div className="chart-container" onClick={onClick}>
<div className="chart-header">
<h3 className="chart-title">{t(titleKey)} ({t("latest.speed_unit")})</h3>
</div>
<div className="chart-body">
<Line data={chartData} options={chartOptions} />
</div>
</div>
);
};
@@ -0,0 +1 @@
export { SpeedChart as default } from "./SpeedChart";
@@ -0,0 +1,53 @@
@use "@/common/styles/colors" as *
.chart-container
display: flex
flex-direction: column
min-width: 16rem
border: 1px solid $light-gray
background-color: var(--glass-bg)
backdrop-filter: blur(16px)
-webkit-backdrop-filter: blur(16px)
border-radius: 1rem
cursor: pointer
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1)
animation: 0.3s fadeIn ease-out
flex: 1 0 30%
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1)
&:hover
border-color: $accent-primary
transform: translateY(-4px)
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15)
.chart-header
display: flex
justify-content: space-between
align-items: center
padding: 1rem 1.25rem 0.75rem 1.25rem
.chart-title
color: $white
font-size: 15pt
font-weight: 600
margin: 0
letter-spacing: -0.01em
.chart-body
flex: 1
min-height: 14rem
padding: 0 1.25rem 1.25rem 1.25rem
position: relative
@media screen and (max-width: 900px)
.chart-container
min-width: 100%
flex: 1 0 100%
@media screen and (max-width: 500px)
.chart-container
padding: 0.5rem
border-radius: 0.75rem
.chart-body
min-height: 180px
@@ -3,7 +3,7 @@ import "./styles.sass";
export const StatisticContainer = (props) => {
return (
<div className={"stats-container" + (props.size ? " container-" + props.size : "")}>
<div className={"stats-container" + (props.size ? " container-" + props.size : "")} onClick={props.onClick}>
<div className="stats-header">
{props.title}
</div>
@@ -4,27 +4,33 @@
display: flex
flex-direction: column
min-width: 16rem
border: 2px solid $light-gray
background-color: $dark-gray
border: 1px solid $light-gray
background-color: var(--glass-bg)
backdrop-filter: blur(16px)
-webkit-backdrop-filter: blur(16px)
border-radius: 1rem
cursor: pointer
transition: all 0.2s ease-in-out
animation: 0.3s fadeIn
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1)
animation: 0.3s fadeIn ease-out
flex: 1 0 15%
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1)
.stats-container:hover
border: 2px solid $green
transform: scale(1.05)
border-color: $accent-primary
transform: translateY(-4px)
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15)
.stats-header
color: $white
font-size: 16pt
font-size: 15pt
font-weight: 600
letter-spacing: -0.01em
border-radius: 1rem 1rem 0 0
padding: 0.75rem 0.5rem 0.5rem 1rem
padding: 1rem 0.75rem 0.75rem 1.25rem
.stats-content
display: flex
padding-bottom: 1rem
padding-bottom: 1.25rem
padding-left: 1.5rem
height: 14rem
padding-right: 1.5rem
+15
View File
@@ -11,6 +11,21 @@
flex-wrap: wrap
transition: 1s all ease-in-out
.statistics-header
width: 100%
display: flex
align-items: center
gap: 1rem
margin-bottom: 0.5rem
.aggregation-info
color: $subtext
font-size: 0.8rem
background-color: $darker-gray
padding: 0.35rem 0.75rem
border-radius: 0.375rem
border: 1px solid $light-gray
@media screen and (max-width: 1472px)
.statistic-area
margin-left: 10rem
+6 -1
View File
@@ -5,7 +5,12 @@ const tests = require("../controller/speedtests");
const axios = require("axios");
async function generateOpenGraphImage(req) {
const test = await tests.listStatistics(1);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const formatDate = (d) => d.toISOString().split('T')[0];
const test = await tests.listStatistics(formatDate(yesterday), formatDate(today));
if (!test.download.avg || !test.upload.avg || !test.ping.avg) {
throw new Error("Error fetching OpenGraph data");
+143 -7
View File
@@ -69,29 +69,165 @@ module.exports.importTests = async (data) => {
return true;
}
module.exports.listStatistics = async (days) => {
let dbEntries = (await tests.findAll({order: [["created", "DESC"]]}))
.filter((entry) => new Date(entry.created) > new Date().getTime() - (days <= 30 ? days : 30 ) * 24 * 3600000);
module.exports.listStatistics = async (fromDate, toDate) => {
const from = new Date(fromDate);
const to = new Date(toDate);
from.setHours(0, 0, 0, 0);
to.setHours(23, 59, 59, 999);
const dbEntries = (await tests.findAll({order: [["created", "DESC"]]}))
.filter((entry) => {
const entryDate = new Date(entry.created);
return entryDate >= from && entryDate <= to;
});
let notFailed = dbEntries.filter((entry) => entry.error === null);
const daysDiff = Math.ceil((to - from) / (1000 * 60 * 60 * 24));
const dataPointCount = notFailed.length;
const MAX_CHART_POINTS = 100;
let aggregationType = 'none';
if (dataPointCount > MAX_CHART_POINTS) {
if (daysDiff > 180 || dataPointCount > MAX_CHART_POINTS * 10) {
aggregationType = 'weekly';
} else if (daysDiff > 7 || dataPointCount > MAX_CHART_POINTS * 3) {
aggregationType = 'daily';
} else {
aggregationType = 'hourly';
}
}
let data = {};
["ping", "download", "upload", "time"].forEach(item => {
data[item] = notFailed.map(entry => entry[item]);
});
const hourlyData = {};
for (let i = 0; i < 24; i++) {
hourlyData[i] = { download: [], upload: [], ping: [] };
}
notFailed.forEach(entry => {
const hour = new Date(entry.created).getHours();
hourlyData[hour].download.push(entry.download);
hourlyData[hour].upload.push(entry.upload);
hourlyData[hour].ping.push(entry.ping);
});
const hourlyAverages = Object.keys(hourlyData).map(hour => ({
hour: parseInt(hour),
download: hourlyData[hour].download.length > 0
? parseFloat((hourlyData[hour].download.reduce((a, b) => a + b, 0) / hourlyData[hour].download.length).toFixed(2))
: null,
upload: hourlyData[hour].upload.length > 0
? parseFloat((hourlyData[hour].upload.reduce((a, b) => a + b, 0) / hourlyData[hour].upload.length).toFixed(2))
: null,
ping: hourlyData[hour].ping.length > 0
? Math.round(hourlyData[hour].ping.reduce((a, b) => a + b, 0) / hourlyData[hour].ping.length)
: null,
count: hourlyData[hour].download.length
}));
const calcStdDev = (arr) => {
if (arr.length < 2) return 0;
const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
const squaredDiffs = arr.map(val => Math.pow(val - mean, 2));
return Math.sqrt(squaredDiffs.reduce((a, b) => a + b, 0) / arr.length);
};
const downloadValues = notFailed.map(e => e.download);
const uploadValues = notFailed.map(e => e.upload);
const pingValues = notFailed.map(e => e.ping);
const downloadMean = downloadValues.length > 0 ? downloadValues.reduce((a, b) => a + b, 0) / downloadValues.length : 0;
const uploadMean = uploadValues.length > 0 ? uploadValues.reduce((a, b) => a + b, 0) / uploadValues.length : 0;
const consistency = {
download: {
stdDev: parseFloat(calcStdDev(downloadValues).toFixed(2)),
consistency: downloadMean > 0 ? parseFloat((100 - (calcStdDev(downloadValues) / downloadMean * 100)).toFixed(1)) : 100
},
upload: {
stdDev: parseFloat(calcStdDev(uploadValues).toFixed(2)),
consistency: uploadMean > 0 ? parseFloat((100 - (calcStdDev(uploadValues) / uploadMean * 100)).toFixed(1)) : 100
},
ping: {
stdDev: parseFloat(calcStdDev(pingValues).toFixed(2)),
jitter: parseFloat(calcStdDev(pingValues).toFixed(2))
}
};
let chartData = data;
let chartLabels = notFailed.map((entry) => new Date(entry.created).toISOString());
if (aggregationType !== 'none' && notFailed.length > 0) {
const aggregated = {};
notFailed.forEach(entry => {
const date = new Date(entry.created);
let key;
if (aggregationType === 'weekly') {
const day = date.getDay();
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
const weekStart = new Date(date);
weekStart.setDate(diff);
key = weekStart.toISOString().split('T')[0];
} else if (aggregationType === 'daily') {
key = date.toISOString().split('T')[0];
} else {
key = date.toISOString().substring(0, 13);
}
if (!aggregated[key]) {
aggregated[key] = { ping: [], download: [], upload: [], time: [], count: 0 };
}
aggregated[key].ping.push(entry.ping);
aggregated[key].download.push(entry.download);
aggregated[key].upload.push(entry.upload);
aggregated[key].time.push(entry.time);
aggregated[key].count++;
});
const sortedKeys = Object.keys(aggregated).sort((a, b) => new Date(a) - new Date(b));
chartLabels = sortedKeys.map(key => {
if (aggregationType === 'hourly') {
return new Date(key + ':00:00.000Z').toISOString();
}
return new Date(key).toISOString();
});
chartData = {
ping: sortedKeys.map(key => Math.round(aggregated[key].ping.reduce((a, b) => a + b, 0) / aggregated[key].ping.length)),
download: sortedKeys.map(key => parseFloat((aggregated[key].download.reduce((a, b) => a + b, 0) / aggregated[key].download.length).toFixed(2))),
upload: sortedKeys.map(key => parseFloat((aggregated[key].upload.reduce((a, b) => a + b, 0) / aggregated[key].upload.length).toFixed(2))),
time: sortedKeys.map(key => Math.round(aggregated[key].time.reduce((a, b) => a + b, 0) / aggregated[key].time.length))
};
}
return {
tests: {
total: dbEntries.length,
failed: dbEntries.length - notFailed.length,
custom: dbEntries.filter((entry) => entry.type === "custom").length
failed: dbEntries.length - notFailed.length
},
ping: mapRounded(notFailed, "ping"),
download: mapFixed(notFailed, "download"),
upload: mapFixed(notFailed, "upload"),
time: mapRounded(notFailed, "time"),
data,
labels: notFailed.map((entry) => new Date(entry.created).toISOString())
data: chartData,
labels: chartLabels,
hourlyAverages,
consistency,
aggregated: aggregationType !== 'none',
aggregationType,
dateRange: {
from: fromDate,
to: toDate,
days: daysDiff
}
};
}

Some files were not shown because too many files have changed in this diff Show More