mirror of
https://github.com/gnmyt/myspeed.git
synced 2026-05-20 21:18:41 -05:00
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
-16
@@ -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
@@ -1 +0,0 @@
|
||||
export {AvailableIntegrations as default} from './AvailableIntegrations';
|
||||
-37
@@ -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
|
||||
-14
@@ -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
@@ -1 +0,0 @@
|
||||
export {IntegrationAddButton as default} from "./IntegrationAddButton";
|
||||
-23
@@ -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
|
||||
-126
@@ -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>
|
||||
)
|
||||
|
||||
}
|
||||
-58
@@ -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
@@ -1 +0,0 @@
|
||||
export {IntegrationItemHeader as default} from "./IntegrationItemHeader";
|
||||
-61
@@ -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"
|
||||
-13
@@ -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";
|
||||
-19
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {NotFound as default} from "./NotFound";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {RouteError as default} from "./RouteError";
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user