diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index 8228db9..2a424c8 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -1,7 +1,7 @@ - 1.3.0 + 1.3.1 Tudor Stanciu STA NetworkResurrector diff --git a/backend/ReleaseNotes.xml b/backend/ReleaseNotes.xml index 6719574..ffc3f95 100644 --- a/backend/ReleaseNotes.xml +++ b/backend/ReleaseNotes.xml @@ -209,7 +209,19 @@ • Unified repository structure - The backend and frontend codebases have been consolidated into a single repository, improving code management and cross-component consistency. • Frontend Upgrades - The frontend has been upgraded to leverage TypeScript, React 18, and Material UI 5, enhancing code quality, UI consistency, and leveraging the latest features of these technologies. • Build process overhaul - I've switched from react-scripts to react-app-rewired. This gives me more control to tweak the webpack configuration as I need. Plus, it's now set up to handle CommonJS (CJS) modules. - • TypeScript refactoring - Several components have been rewritten in TypeScript, improving type safety and predictability in our codebase. + • TypeScript refactoring - Several components have been rewritten in TypeScript, improving type safety and predictability in the codebase. + + + + 1.3.1 + 2024-11-16 02:28 + + SWR integration and progress bar implementation + • SWR has been integrated into the application to handle data fetching, caching, and revalidation. This will help improve the performance of the application by reducing the number of network requests and ensuring that the data is always up to date. + • In this process, the application has been refactored to use SWR hooks for data fetching and caching, replacing the previous custom methods. + • Also, several components have been updated to use TypeScript to continue the migration process started in the previous release. + • A progress bar has been implemented to provide visual feedback to users when data is being fetched or processed. + • The progress bar is displayed at the top of the page and shows the loading status of the application. \ No newline at end of file diff --git a/backend/src/api/NetworkResurrector.Api/Controllers/SystemController.cs b/backend/src/api/NetworkResurrector.Api/Controllers/SystemController.cs index 2964d54..d793932 100644 --- a/backend/src/api/NetworkResurrector.Api/Controllers/SystemController.cs +++ b/backend/src/api/NetworkResurrector.Api/Controllers/SystemController.cs @@ -44,8 +44,9 @@ namespace NetworkResurrector.Api.Controllers [HttpPost("reset-cache")] [Authorize(Policy = Policies.SystemAdministration)] - public async Task WakeMachine([FromBody] ResetCache resetCache) + public async Task ResetCache() { + var resetCache = new ResetCache(); var result = await _mediator.Send(resetCache); return Ok(result); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a7f1422..e7e2ca0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "network-resurrector-frontend", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "network-resurrector-frontend", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", @@ -19,6 +19,7 @@ "i18next": "^22.4.15", "i18next-browser-languagedetector": "^7.0.1", "i18next-http-backend": "^2.2.0", + "lodash": "^4.17.21", "moment": "^2.29.4", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -26,10 +27,12 @@ "react-lazylog": "^4.5.3", "react-router-dom": "^6.10.0", "react-toastify": "^9.1.3", - "react-world-flags": "^1.6.0" + "react-world-flags": "^1.6.0", + "swr": "^2.2.5" }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@types/lodash": "^4.17.7", "@types/react": "^18.2.33", "@types/react-dom": "^18.2.14", "@types/react-world-flags": "^1.4.5", @@ -4890,6 +4893,12 @@ "dev": true, "peer": true }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -6973,6 +6982,11 @@ "node": ">=0.10.0" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -19483,6 +19497,18 @@ "boolbase": "~1.0.0" } }, + "node_modules/swr": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz", + "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==", + "dependencies": { + "client-only": "^0.0.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -20209,6 +20235,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", + "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -24773,6 +24807,12 @@ "dev": true, "peer": true }, + "@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true + }, "@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -26391,6 +26431,11 @@ } } }, + "client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, "cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -35637,6 +35682,15 @@ } } }, + "swr": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz", + "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==", + "requires": { + "client-only": "^0.0.1", + "use-sync-external-store": "^1.2.0" + } + }, "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -36186,6 +36240,12 @@ "requires-port": "^1.0.0" } }, + "use-sync-external-store": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", + "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "requires": {} + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index f3f0c63..982a482 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "network-resurrector-frontend", - "version": "1.3.0", + "version": "1.3.1", "description": "Frontend component of Network resurrector system", "author": { "name": "Tudor Stanciu", @@ -24,6 +24,7 @@ "i18next": "^22.4.15", "i18next-browser-languagedetector": "^7.0.1", "i18next-http-backend": "^2.2.0", + "lodash": "^4.17.21", "moment": "^2.29.4", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -31,10 +32,12 @@ "react-lazylog": "^4.5.3", "react-router-dom": "^6.10.0", "react-toastify": "^9.1.3", - "react-world-flags": "^1.6.0" + "react-world-flags": "^1.6.0", + "swr": "^2.2.5" }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@types/lodash": "^4.17.7", "@types/react": "^18.2.33", "@types/react-dom": "^18.2.14", "@types/react-world-flags": "^1.4.5", diff --git a/frontend/src/components/layout/ProfileButton.js b/frontend/src/components/layout/ProfileButton.js index 5843a57..5e92221 100644 --- a/frontend/src/components/layout/ProfileButton.js +++ b/frontend/src/components/layout/ProfileButton.js @@ -6,17 +6,16 @@ import AccountBoxIcon from "@mui/icons-material/AccountBox"; import SettingsIcon from "@mui/icons-material/Settings"; import { useNavigate } from "react-router-dom"; import { useTuitioClient } from "@flare/tuitio-client-react"; -import { useToast } from "../../hooks"; +import { blip } from "../../utils"; import { useTranslation } from "react-i18next"; const ProfileButton = () => { const navigate = useNavigate(); - const { error } = useToast(); const { t } = useTranslation(); const { logout } = useTuitioClient({ - onLogoutFailed: errorMessage => error(errorMessage), - onLogoutError: err => error(err.message) + onLogoutFailed: errorMessage => blip.error(errorMessage), + onLogoutError: err => blip.error(err.message) }); const [anchorEl, setAnchorEl] = useState(null); diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 80705a4..4e4378d 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -7,6 +7,7 @@ import LightDarkToggle from "./LightDarkToggle"; import SensitiveInfoToggle from "./SensitiveInfoToggle"; import { styled } from "@mui/material/styles"; import { drawerWidth } from "./constants"; +import { ProgressBar } from "units/progress"; interface AppBarProps extends MuiAppBarProps { open?: boolean; @@ -64,7 +65,7 @@ const TopBar: React.FC = ({ open, onDrawerOpen }) => { - {/* */} + ); }; diff --git a/frontend/src/features/about/releaseNotes/ReleaseNotesContainer.js b/frontend/src/features/about/releaseNotes/ReleaseNotesContainer.js deleted file mode 100644 index 39e849b..0000000 --- a/frontend/src/features/about/releaseNotes/ReleaseNotesContainer.js +++ /dev/null @@ -1,35 +0,0 @@ -import React, { useState, useEffect } from "react"; -import PropTypes from "prop-types"; -import ReleaseNotesList from "./ReleaseNotesList"; -import TimelineComponent from "../timeline/TimelineComponent"; -import { routes, get } from "../../../utils/api"; - -const sort = releases => releases.sort((a, b) => new Date(b.date) - new Date(a.date)); - -const ReleaseNotesContainer = ({ view }) => { - const [state, setState] = useState({ data: [], loaded: false }); - - useEffect(() => { - if (state.loaded) return; - get(routes.releaseNotes, { - onCompleted: data => setState({ data, loaded: true }) - }); - }, [state.loaded]); - - return ( - <> - {state.loaded && - (view === "timeline" ? ( - - ) : ( - - ))} - - ); -}; - -ReleaseNotesContainer.propTypes = { - view: PropTypes.string -}; - -export default ReleaseNotesContainer; diff --git a/frontend/src/features/about/releaseNotes/ReleaseNotesContainer.tsx b/frontend/src/features/about/releaseNotes/ReleaseNotesContainer.tsx new file mode 100644 index 0000000..28d837b --- /dev/null +++ b/frontend/src/features/about/releaseNotes/ReleaseNotesContainer.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import ReleaseNotesList from "./ReleaseNotesList"; +import TimelineComponent from "../timeline/TimelineComponent"; +import { endpoints } from "../../../utils/api"; +import { useSWR, fetcher } from "units/swr"; +import { blip } from "utils"; +import { dtos } from "types"; + +const sort = (releases: dtos.ReleaseNote[]) => + releases.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + +type Props = { + view: string; +}; + +const ReleaseNotesContainer: React.FC = ({ view }) => { + const { data, isLoading } = useSWR(endpoints.system.releaseNotes, fetcher, { + revalidateOnFocus: false, + onError: err => blip.error(err.message) + }); + + if (isLoading || !data) return null; + + return ( + <> + {view === "timeline" ? : } + + ); +}; + +export default ReleaseNotesContainer; diff --git a/frontend/src/features/about/system/AboutSystemComponent.js b/frontend/src/features/about/system/AboutSystemComponent.js index 2108211..e2b4909 100644 --- a/frontend/src/features/about/system/AboutSystemComponent.js +++ b/frontend/src/features/about/system/AboutSystemComponent.js @@ -38,7 +38,7 @@ const buttons = [ } ]; -const AboutSystemComponent = ({ handleOpenInNewTab }) => { +const AboutSystemComponent = ({ onOpenInNewTab }) => { const { t } = useTranslation(); const bullet = ; @@ -80,7 +80,7 @@ const AboutSystemComponent = ({ handleOpenInNewTab }) => { size="small" color="primary" startIcon={} - onClick={handleOpenInNewTab(button.url)} + onClick={onOpenInNewTab(button.url)} > {t(button.code)} @@ -91,7 +91,7 @@ const AboutSystemComponent = ({ handleOpenInNewTab }) => { }; AboutSystemComponent.propTypes = { - handleOpenInNewTab: PropTypes.func.isRequired + onOpenInNewTab: PropTypes.func.isRequired }; export default AboutSystemComponent; diff --git a/frontend/src/features/about/system/AboutSystemContainer.js b/frontend/src/features/about/system/AboutSystemContainer.js index 3396f98..1b72003 100644 --- a/frontend/src/features/about/system/AboutSystemContainer.js +++ b/frontend/src/features/about/system/AboutSystemContainer.js @@ -13,15 +13,15 @@ const styles = { } }; -const AboutSystemContainer = () => { - const handleOpenInNewTab = url => event => { - window.open(url, "_blank"); - event.preventDefault(); - }; +const handleOpenInNewTab = url => event => { + window.open(url, "_blank"); + event.preventDefault(); +}; +const AboutSystemContainer = () => { return ( - + diff --git a/frontend/src/features/about/system/SystemVersionComponent.js b/frontend/src/features/about/system/SystemVersionComponent.tsx similarity index 59% rename from frontend/src/features/about/system/SystemVersionComponent.js rename to frontend/src/features/about/system/SystemVersionComponent.tsx index 2b49f80..853bc73 100644 --- a/frontend/src/features/about/system/SystemVersionComponent.js +++ b/frontend/src/features/about/system/SystemVersionComponent.tsx @@ -1,6 +1,5 @@ -import React, { useMemo, useEffect, useState } from "react"; -import PropTypes from "prop-types"; -import { List, ListItem, ListItemText, ListItemAvatar } from "@mui/material"; +import React, { useMemo } from "react"; +import { List, ListItem, ListItemText, ListItemAvatar, styled } from "@mui/material"; import Avatar from "@mui/material/Avatar"; import WebAssetIcon from "@mui/icons-material/WebAsset"; import DeveloperBoardIcon from "@mui/icons-material/DeveloperBoard"; @@ -8,47 +7,23 @@ import SettingsInputSvideoIcon from "@mui/icons-material/SettingsInputSvideo"; import { useTranslation } from "react-i18next"; import packageData from "../../../../package.json"; import Paper from "@mui/material/Paper"; -import { useTheme } from "@mui/material/styles"; +import { dtos } from "types"; -const getStyles = theme => ({ - horizontally: { - display: "flex", - flexDirection: "row", - padding: 0 - }, - vertical: { - width: "100%" - }, - value: { - fontSize: "0.9rem", - fontWeight: theme.typography.fontWeightMedium - }, - versionAvatar: { - backgroundColor: theme.palette.secondary.main - } -}); +const VersionAvatar = styled(Avatar)(({ theme }) => ({ + backgroundColor: theme.palette.secondary.main +})); -const SystemVersionComponent = ({ data }) => { +const VersionLabel = styled("span")(({ theme }) => ({ + fontSize: "0.9rem", + fontWeight: theme.typography.fontWeightMedium +})); + +type Props = { + data: dtos.SystemVersion; +}; + +const SystemVersionComponent: React.FC = ({ data }) => { const { t } = useTranslation(); - const theme = useTheme(); - const styles = getStyles(theme); - const [listClass, setListClass] = useState(styles.horizontally); - - useEffect(() => { - const mediaQuery = window.matchMedia("(max-width: 800px)"); - - function handleMatches(event) { - const cssClass = event.matches ? styles.vertical : styles.horizontally; - setListClass(cssClass); - } - - handleMatches(mediaQuery); - mediaQuery.addListener(handleMatches); - - return () => { - mediaQuery.removeListener(handleMatches); - }; - }, [styles.horizontally, styles.vertical]); const lastReleaseDate = useMemo(() => { const format = "DD-MM-YYYY HH:mm:ss"; @@ -78,20 +53,27 @@ const SystemVersionComponent = ({ data }) => { return ( - + - + - + + {t("About.System.Version.Server", { version: data.server.version })} - + } secondary={t("About.System.Version.LastReleaseDate", { date: lastReleaseDate.server @@ -100,17 +82,17 @@ const SystemVersionComponent = ({ data }) => { - + - + + {t("About.System.Version.Api", { version: data.api.version })} - + } secondary={t("About.System.Version.LastReleaseDate", { date: lastReleaseDate.api @@ -119,17 +101,17 @@ const SystemVersionComponent = ({ data }) => { - + - + + {t("About.System.Version.Frontend", { version: process.env.APP_VERSION ?? packageData.version })} - + } secondary={t("About.System.Version.LastReleaseDate", { date: lastReleaseDate.frontend @@ -141,8 +123,4 @@ const SystemVersionComponent = ({ data }) => { ); }; -SystemVersionComponent.propTypes = { - data: PropTypes.object.isRequired -}; - export default SystemVersionComponent; diff --git a/frontend/src/features/about/system/SystemVersionContainer.js b/frontend/src/features/about/system/SystemVersionContainer.js deleted file mode 100644 index c4731fa..0000000 --- a/frontend/src/features/about/system/SystemVersionContainer.js +++ /dev/null @@ -1,18 +0,0 @@ -import React, { useState, useEffect } from "react"; -import SystemVersionComponent from "./SystemVersionComponent"; -import { routes, get } from "../../../utils/api"; - -const SystemVersionContainer = () => { - const [state, setState] = useState({ data: {}, loaded: false }); - - useEffect(() => { - if (state.loaded) return; - get(routes.systemVersion, { - onCompleted: data => setState({ data, loaded: true }) - }); - }, [state.loaded]); - - return <>{state.loaded && }; -}; - -export default SystemVersionContainer; diff --git a/frontend/src/features/about/system/SystemVersionContainer.tsx b/frontend/src/features/about/system/SystemVersionContainer.tsx new file mode 100644 index 0000000..95cde2d --- /dev/null +++ b/frontend/src/features/about/system/SystemVersionContainer.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import SystemVersionComponent from "./SystemVersionComponent"; +import { endpoints } from "../../../utils/api"; +import { useSWR, fetcher } from "units/swr"; +import { blip } from "utils"; +import { dtos } from "types"; + +const SystemVersionContainer: React.FC = () => { + const { data, isLoading } = useSWR(endpoints.system.version, fetcher, { + revalidateOnFocus: false, + onError: err => blip.error(err.message) + }); + + if (isLoading || !data) return null; + return ; +}; + +export default SystemVersionContainer; diff --git a/frontend/src/features/about/timeline/TimelineComponent.js b/frontend/src/features/about/timeline/TimelineComponent.js index d8eb446..2868018 100644 --- a/frontend/src/features/about/timeline/TimelineComponent.js +++ b/frontend/src/features/about/timeline/TimelineComponent.js @@ -74,6 +74,9 @@ const TimelineComponent = ({ releases }) => { date: { value: release.date, format: "DD-MM-YYYY HH:mm" } })} + + {t("About.ReleaseNotes.Version")}: {release.version} + diff --git a/frontend/src/features/login/components/LoginContainer.js b/frontend/src/features/login/components/LoginContainer.js index c3f23af..150790d 100644 --- a/frontend/src/features/login/components/LoginContainer.js +++ b/frontend/src/features/login/components/LoginContainer.js @@ -1,6 +1,6 @@ import React, { useState } from "react"; import LoginCard from "./LoginCard"; -import { useToast } from "../../../hooks"; +import { blip } from "../../../utils"; import { useTranslation } from "react-i18next"; import { useTuitioClient } from "@flare/tuitio-client-react"; @@ -10,11 +10,10 @@ const LoginContainer = () => { password: "" }); - const { error } = useToast(); const { t } = useTranslation(); const { login } = useTuitioClient({ - onLoginFailed: () => error(t("Login.IncorrectCredentials")), - onLoginError: err => error(err.message) + onLoginFailed: () => blip.error(t("Login.IncorrectCredentials")), + onLoginError: err => blip.error(err.message) }); const handleChange = prop => event => { diff --git a/frontend/src/features/machines/components/MachineAccordion.tsx b/frontend/src/features/machines/components/MachineAccordion.tsx index e78598b..dc45a6e 100644 --- a/frontend/src/features/machines/components/MachineAccordion.tsx +++ b/frontend/src/features/machines/components/MachineAccordion.tsx @@ -66,9 +66,9 @@ const GridCell: React.FC = ({ label, value }) => { type Props = { machine: models.Machine; - actions: Array; // Replace any with the actual type of the actions - logs: Array; // Replace any with the actual type of the logs - addLog: () => void; // Replace with the actual function signature + actions: Array; + logs: Array; + addLog: (message: string) => void; }; const MachineAccordion: React.FC = ({ machine, actions, logs, addLog }) => { diff --git a/frontend/src/features/machines/components/MachineContainer.js b/frontend/src/features/machines/components/MachineContainer.tsx similarity index 51% rename from frontend/src/features/machines/components/MachineContainer.js rename to frontend/src/features/machines/components/MachineContainer.tsx index e1a3236..31d02af 100644 --- a/frontend/src/features/machines/components/MachineContainer.js +++ b/frontend/src/features/machines/components/MachineContainer.tsx @@ -1,75 +1,82 @@ import React, { useState, useCallback } from "react"; -import PropTypes from "prop-types"; import MachineTableRow from "./MachineTableRow"; import MachineAccordion from "./MachineAccordion"; import { ViewModes } from "./ViewModeSelection"; -import { useToast } from "../../../hooks"; +import { blip } from "../../../utils"; import { LastPage, RotateLeft, Launch, Stop } from "@mui/icons-material"; import { useTranslation } from "react-i18next"; -import { routes, post } from "../../../utils/api"; +import { endpoints } from "../../../utils/api"; +import { + Machine, + MachineActionResult, + MachineRestarted, + MachineShutdown, + RestartMachine, + ShutdownMachine +} from "types"; +import { Key, NetworkError, mutationFetcher, useGuardedMutation } from "units/swr"; +import { usePingTrigger } from "../hooks"; -const MachineContainer = ({ machine, viewMode }) => { - const [logs, setLogs] = useState([]); - - const { success, error } = useToast(); +type Props = { + machine: Machine; + viewMode: string; +}; +const MachineContainer: React.FC = ({ machine, viewMode }) => { + const [logs, setLogs] = useState([]); const { t } = useTranslation(); const addLog = useCallback( - text => { + (text: string) => { setLogs(prev => [...prev, text]); }, [setLogs] ); const manageActionResponse = useCallback( - response => { + (response: MachineActionResult) => { addLog(`Success: ${response.success}. Status: ${response.status}`); if (response.success) { - success(response.status); + blip.success(response.status); } else { - error(response.status); + blip.error(response.status); } }, - [error, success, addLog] + [addLog] + ); + + const { pingMachineTrigger } = usePingTrigger({ + onSuccess: manageActionResponse + }); + + const { trigger: shutdownMachineTrigger } = useGuardedMutation( + endpoints.network.machine.shutdown, + mutationFetcher, + { + onSuccess: manageActionResponse + } + ); + + const { trigger: restartMachineTrigger } = useGuardedMutation( + endpoints.network.machine.restart, + mutationFetcher, + { + onSuccess: manageActionResponse + } ); const pingMachine = useCallback( - async machine => { - await post( - routes.pingMachine, - { machineId: machine.machineId }, - { - onCompleted: manageActionResponse - } - ); - }, - [manageActionResponse] + async (machine: Machine) => pingMachineTrigger({ machineId: machine.machineId }), + [pingMachineTrigger] ); const shutdownMachine = useCallback( - async machine => { - await post( - routes.shutdownMachine, - { machineId: machine.machineId, delay: 0, force: false }, - { - onCompleted: manageActionResponse - } - ); - }, - [manageActionResponse] + async (machine: Machine) => shutdownMachineTrigger({ machineId: machine.machineId, delay: 0, force: false }), + [shutdownMachineTrigger] ); const restartMachine = useCallback( - async machine => { - await post( - routes.restartMachine, - { machineId: machine.machineId, delay: 0, force: false }, - { - onCompleted: manageActionResponse - } - ); - }, - [manageActionResponse] + async (machine: Machine) => restartMachineTrigger({ machineId: machine.machineId, delay: 0, force: false }), + [restartMachineTrigger] ); const actions = [ @@ -117,9 +124,4 @@ const MachineContainer = ({ machine, viewMode }) => { ); }; -MachineContainer.propTypes = { - machine: PropTypes.object.isRequired, - viewMode: PropTypes.string.isRequired -}; - export default MachineContainer; diff --git a/frontend/src/features/machines/components/MachinesContainer.js b/frontend/src/features/machines/components/MachinesContainer.tsx similarity index 58% rename from frontend/src/features/machines/components/MachinesContainer.js rename to frontend/src/features/machines/components/MachinesContainer.tsx index 46a09d1..e731139 100644 --- a/frontend/src/features/machines/components/MachinesContainer.js +++ b/frontend/src/features/machines/components/MachinesContainer.tsx @@ -1,32 +1,30 @@ -import React, { useContext, useEffect, useCallback, useState } from "react"; +import React, { useContext, useState } from "react"; import { NetworkStateContext, NetworkDispatchContext } from "../../network/state/contexts"; import MachinesListComponent from "./MachinesListComponent"; import PageTitle from "../../../components/common/PageTitle"; import { useTranslation } from "react-i18next"; import ViewModeSelection, { ViewModes } from "./ViewModeSelection"; -import { routes, get } from "../../../utils/api"; +import { endpoints } from "../../../utils/api"; +import { useSWR, fetcher } from "units/swr"; +import { blip } from "utils"; +import { Machine } from "types"; -const MachinesContainer = () => { +const MachinesContainer: React.FC = () => { const [viewMode, setViewMode] = useState(null); const state = useContext(NetworkStateContext); const dispatchActions = useContext(NetworkDispatchContext); const { t } = useTranslation(); - const handleReadMachines = useCallback(async () => { - await get(routes.machines, { - onCompleted: machines => { - const data = Object.assign(machines, { loaded: true }); - dispatchActions.onNetworkChange("machines", data); - } - }); - }, [dispatchActions]); - - useEffect(() => { - if (!state.network.machines.loaded) { - handleReadMachines(); + const url = state.network.machines.loaded ? null : endpoints.network.machines; + useSWR(url, fetcher, { + revalidateOnFocus: false, + onError: err => blip.error(err.message), + onSuccess: machines => { + const data = Object.assign(machines, { loaded: true }); + dispatchActions.onNetworkChange("machines", data); } - }, [handleReadMachines, state.network.machines.loaded]); + }); return ( <> diff --git a/frontend/src/features/machines/components/common/WakeComponent.js b/frontend/src/features/machines/components/common/WakeComponent.js deleted file mode 100644 index 6f7b0bc..0000000 --- a/frontend/src/features/machines/components/common/WakeComponent.js +++ /dev/null @@ -1,115 +0,0 @@ -import React, { useState, useEffect, useCallback } from "react"; -import PropTypes from "prop-types"; -import { IconButton, Tooltip } from "@mui/material"; -import { PowerSettingsNew } from "@mui/icons-material"; -import { useTranslation } from "react-i18next"; -import { useToast } from "../../../../hooks"; -import { msToMinAndSec } from "../../../../utils/time"; -import { routes, post } from "../../../../utils/api"; - -const initialState = { on: false }; -const defaultPingInterval = 1200000; //20 minutes -const defaultStartingTime = 300000; //5 minutes - -const WakeComponent = ({ machine, addLog, disabled }) => { - const [state, setState] = useState(initialState); - const [trigger, setTrigger] = useState(false); - - const { t } = useTranslation(); - const { success, error } = useToast(); - - const pingInterval = process.env.REACT_APP_MACHINE_PING_INTERVAL || defaultPingInterval; - const startingTime = process.env.REACT_APP_MACHINE_STARTING_TIME || defaultStartingTime; - - const getCurrentDateTime = useCallback(() => { - const currentDateTime = Date.now(); - const result = t("DATE_FORMAT", { - date: { value: currentDateTime, format: "DD-MM-YYYY HH:mm:ss" } - }); - return result; - }, [t]); - - const log = useCallback(message => addLog(`[${getCurrentDateTime()}] ${message}`), [addLog, getCurrentDateTime]); - - const wakeMachine = useCallback(async () => { - await post( - routes.wakeMachine, - { machineId: machine.machineId }, - { - onCompleted: result => { - setState(prev => ({ ...prev, on: result.success })); - log(`[Wake]: Success: ${result.success}. Status: ${result.status}`); - if (result.success) { - success(result.status); - - //retrigger - log(`Periodic ping will be re-triggered in ${startingTime} ms [${msToMinAndSec(startingTime)}]`); - setTimeout(() => { - setTrigger(prev => !prev); - }, startingTime); - } else { - error(result.status); - } - } - } - ); - }, [log, success, error, startingTime, machine.machineId]); - - const pingInLoop = useCallback(async () => { - if (disabled) return; - await post( - routes.pingMachine, - { machineId: machine.machineId }, - { - onCompleted: result => { - setState(prev => ({ ...prev, on: result.success })); - log(`[Ping]: Success: ${result.success}. Status: ${result.status}`); - - if (result.success) { - setTimeout(() => { - setTrigger(prev => !prev); - }, pingInterval); - } - }, - onError: () => { - // to do: handle error - } - } - ); - }, [machine, log, pingInterval, disabled]); - - useEffect(() => { - pingInLoop(); - }, [trigger, pingInLoop]); - - const handleWakeClick = event => { - wakeMachine(); - event.stopPropagation(); - }; - - return ( - - - event.stopPropagation()} - > - - - - - ); -}; - -WakeComponent.propTypes = { - machine: PropTypes.object.isRequired, - addLog: PropTypes.func.isRequired, - disabled: PropTypes.bool -}; - -export default WakeComponent; diff --git a/frontend/src/features/machines/components/common/WakeComponent.tsx b/frontend/src/features/machines/components/common/WakeComponent.tsx new file mode 100644 index 0000000..3e1aa9e --- /dev/null +++ b/frontend/src/features/machines/components/common/WakeComponent.tsx @@ -0,0 +1,117 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { IconButton, Tooltip } from "@mui/material"; +import { PowerSettingsNew } from "@mui/icons-material"; +import { useTranslation } from "react-i18next"; +import { blip } from "../../../../utils"; +import { msToMinAndSec } from "../../../../utils/time"; +import { endpoints } from "../../../../utils/api"; +import { Machine, MachineWaked, WakeMachine } from "types"; +import { usePingTrigger } from "../../hooks"; +import { Key, mutationFetcher, NetworkError, useSWRMutation } from "units/swr"; + +const initialState = { on: false }; +const defaultPingInterval = 1200000; //20 minutes +const defaultStartingTime = 300000; //5 minutes + +const pingInterval = parseInt(process.env.REACT_APP_MACHINE_PING_INTERVAL ?? "") || defaultPingInterval; +const startingTime = parseInt(process.env.REACT_APP_MACHINE_STARTING_TIME ?? "") || defaultStartingTime; + +type Props = { + machine: Machine; + addLog: (message: string) => void; + disabled: boolean; +}; + +const WakeComponent: React.FC = ({ machine, addLog, disabled }) => { + const [state, setState] = useState(initialState); + const [trigger, setTrigger] = useState(false); + + const { t } = useTranslation(); + + const getCurrentDateTime = useCallback(() => { + const currentDateTime = Date.now(); + const result = t("DATE_FORMAT", { + date: { value: currentDateTime, format: "DD-MM-YYYY HH:mm:ss" } + }); + return result; + }, [t]); + + const log = useCallback( + (message: string) => addLog(`[${getCurrentDateTime()}] ${message}`), + [addLog, getCurrentDateTime] + ); + + const { pingMachineTrigger } = usePingTrigger({ + onSuccess: result => { + setState(prev => ({ ...prev, on: result.success })); + log(`[Ping]: Success: ${result.success}. Status: ${result.status}`); + + // trigger the next ping + if (result.success) { + setTimeout(() => { + setTrigger(prev => !prev); + }, pingInterval); + } + }, + onError: () => { + // to do: handle error + } + }); + + const { trigger: wakeMachineTrigger } = useSWRMutation( + endpoints.network.machine.wake, + mutationFetcher, + { + onError: err => blip.error(err.message), + onSuccess: result => { + setState(prev => ({ ...prev, on: result.success })); + log(`[Wake]: Success: ${result.success}. Status: ${result.status}`); + if (result.success) { + blip.success(result.status); + + //retrigger + log(`Periodic ping will be re-triggered in ${startingTime} ms [${msToMinAndSec(startingTime)}]`); + setTimeout(() => { + setTrigger(prev => !prev); + }, startingTime); + } else { + blip.error(result.status); + } + } + } + ); + + const pingMachine = useCallback(async () => { + if (disabled) return; + pingMachineTrigger({ machineId: machine.machineId }); + }, [machine.machineId, pingMachineTrigger, disabled]); + + useEffect(() => { + pingMachine(); + }, [trigger, pingMachine]); + + const handleWakeClick = (event: React.MouseEvent) => { + wakeMachineTrigger({ machineId: machine.machineId }); + event.stopPropagation(); + }; + + return ( + + + event.stopPropagation()} + > + + + + + ); +}; + +export default WakeComponent; diff --git a/frontend/src/features/machines/hooks/index.ts b/frontend/src/features/machines/hooks/index.ts new file mode 100644 index 0000000..90fd598 --- /dev/null +++ b/frontend/src/features/machines/hooks/index.ts @@ -0,0 +1,3 @@ +import usePingTrigger from "./usePingTrigger"; + +export { usePingTrigger }; diff --git a/frontend/src/features/machines/hooks/usePingTrigger.ts b/frontend/src/features/machines/hooks/usePingTrigger.ts new file mode 100644 index 0000000..f725132 --- /dev/null +++ b/frontend/src/features/machines/hooks/usePingTrigger.ts @@ -0,0 +1,26 @@ +import { useMemo } from "react"; +import { MachinePinged, PingMachine } from "types"; +import { Key, mutationFetcher, NetworkError, useSWRMutation } from "units/swr"; +import { blip } from "utils"; +import { endpoints } from "utils/api"; + +type PingTriggerOptions = { + onSuccess: (response: MachinePinged) => void; + onError?: (error: Error) => void; +}; +const usePingTrigger = (options: PingTriggerOptions) => { + const { onSuccess } = options; + const onError = useMemo(() => options.onError || ((error: Error) => blip.error(error.message)), [options.onError]); + + const { trigger: pingMachineTrigger } = useSWRMutation( + endpoints.network.machine.ping, + mutationFetcher, + { + onError, + onSuccess, + throwOnError: false + } + ); + return { pingMachineTrigger }; +}; +export default usePingTrigger; diff --git a/frontend/src/features/settings/system/CacheSettingsContainer.js b/frontend/src/features/settings/system/CacheSettingsContainer.js deleted file mode 100644 index 4283fcd..0000000 --- a/frontend/src/features/settings/system/CacheSettingsContainer.js +++ /dev/null @@ -1,22 +0,0 @@ -import React, { useCallback } from "react"; -import CacheSettingsComponent from "./CacheSettingsComponent"; -import { useTranslation } from "react-i18next"; -import { routes, post } from "utils/api"; -import { info } from "utils/toast"; - -const CacheSettingsContainer = () => { - const { t } = useTranslation(); - const handleResetCache = useCallback(async () => { - await post( - routes.resetCache, - {}, - { - onCompleted: () => info(t("Settings.Cache.ResetInfo")) - } - ); - }, [t]); - - return ; -}; - -export default CacheSettingsContainer; diff --git a/frontend/src/features/settings/system/CacheSettingsContainer.tsx b/frontend/src/features/settings/system/CacheSettingsContainer.tsx new file mode 100644 index 0000000..39cec63 --- /dev/null +++ b/frontend/src/features/settings/system/CacheSettingsContainer.tsx @@ -0,0 +1,24 @@ +import React, { useCallback } from "react"; +import CacheSettingsComponent from "./CacheSettingsComponent"; +import { useTranslation } from "react-i18next"; +import { endpoints } from "utils/api"; +import { blip } from "utils"; +import { useGuardedMutation, mutationFetcher, Key, NetworkError } from "units/swr"; + +const CacheSettingsContainer: React.FC = () => { + const { t } = useTranslation(); + + const { trigger } = useGuardedMutation( + endpoints.system.resetCache, + mutationFetcher, + { + onSuccess: () => blip.info(t("Settings.Cache.ResetInfo")) + } + ); + + const handleResetCache = useCallback(() => trigger(), [trigger]); + + return ; +}; + +export default CacheSettingsContainer; diff --git a/frontend/src/hooks/index.js b/frontend/src/hooks/index.js index 44ec796..f28fc19 100644 --- a/frontend/src/hooks/index.js +++ b/frontend/src/hooks/index.js @@ -1,6 +1,5 @@ -import { useToast } from "./useToast"; import { useSensitiveInfo } from "../providers/SensitiveInfoProvider"; import { usePermissions } from "../providers/UserPermissionsProvider"; import { useClipboard } from "./useClipboard"; -export { useToast, useSensitiveInfo, usePermissions, useClipboard }; +export { useSensitiveInfo, usePermissions, useClipboard }; diff --git a/frontend/src/hooks/useClipboard.js b/frontend/src/hooks/useClipboard.js index b2fd329..956f5e2 100644 --- a/frontend/src/hooks/useClipboard.js +++ b/frontend/src/hooks/useClipboard.js @@ -1,16 +1,15 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { useToast } from "./useToast"; +import { blip } from "../utils"; const useClipboard = () => { const { t } = useTranslation(); - const { info } = useToast(); const copy = useCallback( url => () => { navigator.clipboard.writeText(url); - info(t("Generic.CopiedToClipboard")); + blip.info(t("Generic.CopiedToClipboard")); }, - [info, t] + [t] ); return { copy }; }; diff --git a/frontend/src/hooks/useToast.js b/frontend/src/hooks/useToast.js deleted file mode 100644 index 2255151..0000000 --- a/frontend/src/hooks/useToast.js +++ /dev/null @@ -1,5 +0,0 @@ -import { info, success, warning, error, dark } from "utils/toast"; - -export const useToast = () => { - return { info, success, warning, error, dark }; -}; diff --git a/frontend/src/providers/ThemeProvider.js b/frontend/src/providers/ThemeProvider.js index 740a1f1..f81ecf1 100644 --- a/frontend/src/providers/ThemeProvider.js +++ b/frontend/src/providers/ThemeProvider.js @@ -2,7 +2,7 @@ import React, { useReducer, useMemo, useContext } from "react"; import PropTypes from "prop-types"; import { ThemeProvider as MuiThemeProvider } from "@mui/material/styles"; import { localStorage } from "@flare/js-utils"; -import { getThemes } from "../themes"; +import { getThemes } from "../units/themes"; const ApplicationThemeContext = React.createContext(); const LOCAL_STORAGE_COLOR_SCHEME_KEY = "network-resurrector-color-scheme"; diff --git a/frontend/src/providers/UserPermissionsProvider.js b/frontend/src/providers/UserPermissionsProvider.tsx similarity index 60% rename from frontend/src/providers/UserPermissionsProvider.js rename to frontend/src/providers/UserPermissionsProvider.tsx index fbc29af..6d80d41 100644 --- a/frontend/src/providers/UserPermissionsProvider.js +++ b/frontend/src/providers/UserPermissionsProvider.tsx @@ -1,6 +1,8 @@ -import React, { useState, useEffect, useContext, useMemo } from "react"; -import PropTypes from "prop-types"; -import { routes, get } from "../utils/api"; +import React, { useState, useContext, useMemo, ReactNode } from "react"; +import { endpoints } from "../utils/api"; +import { PermissionsDto } from "types/dtos"; +import { useSWR, fetcher } from "units/swr"; +import { blip } from "utils"; const permissionCodes = { VIEW_DASHBOARD: "VIEW_DASHBOARD", @@ -13,12 +15,17 @@ const permissionCodes = { SYSTEM_ADMINISTRATION: "SYSTEM_ADMINISTRATION" }; -const initialState = { +type UserPermissionsContextPayload = { + permissions: string[]; + loading: boolean; +}; + +const initialState: UserPermissionsContextPayload = { permissions: [], loading: true }; -const getPermissionFlags = permissions => { +const getPermissionFlags = (permissions: string[]) => { const viewDashboard = permissions.includes(permissionCodes.VIEW_DASHBOARD) ?? false; const manageUsers = permissions.includes(permissionCodes.MANAGE_USERS) ?? false; const manageSettings = permissions.includes(permissionCodes.MANAGE_SETTINGS) ?? false; @@ -49,7 +56,7 @@ const getPermissionFlags = permissions => { }; }; -const UserPermissionsContext = React.createContext(initialState); +const UserPermissionsContext = React.createContext(initialState); const usePermissions = () => { const { permissions, loading } = useContext(UserPermissionsContext); @@ -57,20 +64,21 @@ const usePermissions = () => { return { loading, ...flags }; }; -const UserPermissionsProvider = ({ children }) => { - const [permissions, setPermissions] = useState(initialState); - - useEffect(() => { - get(routes.permissions, { - onCompleted: data => setPermissions({ ...data, loading: false }) - }); - }, []); - - return {children}; +type Props = { + children: ReactNode; }; -UserPermissionsProvider.propTypes = { - children: PropTypes.node.isRequired +const UserPermissionsProvider: React.FC = ({ children }) => { + const [state, setState] = useState(initialState); + + const url = useMemo(() => (state.permissions?.length ? null : endpoints.security.permissions), [state.permissions]); + useSWR(url, fetcher, { + revalidateOnFocus: false, + onError: err => blip.error(err.message), + onSuccess: data => setState({ ...data, loading: false }) + }); + + return {children}; }; export { UserPermissionsProvider, usePermissions }; diff --git a/frontend/src/types/commands.ts b/frontend/src/types/commands.ts new file mode 100644 index 0000000..4ad5b28 --- /dev/null +++ b/frontend/src/types/commands.ts @@ -0,0 +1,19 @@ +export type WakeMachine = { + machineId: number; +}; + +export type PingMachine = { + machineId: number; +}; + +export type ShutdownMachine = { + machineId: number; + delay?: number; + force?: boolean; +}; + +export type RestartMachine = { + machineId: number; + delay?: number; + force?: boolean; +}; diff --git a/frontend/src/types/dtos.ts b/frontend/src/types/dtos.ts new file mode 100644 index 0000000..bdaee66 --- /dev/null +++ b/frontend/src/types/dtos.ts @@ -0,0 +1,19 @@ +export type SystemVersionElement = { + version: string; + lastReleaseDate: string; +}; + +export type SystemVersion = { + api: SystemVersionElement; + server: SystemVersionElement; +}; + +export type ReleaseNote = { + version: string; + date: string; + notes: string[]; +}; + +export type PermissionsDto = { + permissions: string[]; +}; diff --git a/frontend/src/types/events.ts b/frontend/src/types/events.ts new file mode 100644 index 0000000..64602a3 --- /dev/null +++ b/frontend/src/types/events.ts @@ -0,0 +1,9 @@ +export type MachineActionResult = { + success: boolean; + status: string; +}; + +export type MachineWaked = MachineActionResult; +export type MachinePinged = MachineActionResult; +export type MachineShutdown = MachineActionResult; +export type MachineRestarted = MachineActionResult; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b471b7f..fef5878 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,5 +1,10 @@ import * as models from "./models"; +import * as dtos from "./dtos"; +import * as commands from "./commands"; +import * as events from "./events"; export * from "./models"; +export * from "./commands"; +export * from "./events"; -export { models }; +export { models, dtos, commands, events }; diff --git a/frontend/src/units/progress/ProgressBar.tsx b/frontend/src/units/progress/ProgressBar.tsx new file mode 100644 index 0000000..5682420 --- /dev/null +++ b/frontend/src/units/progress/ProgressBar.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { Box, LinearProgress } from "@mui/material"; +import { useProgressBar } from "./hooks"; + +const ProgressBar: React.FC = () => { + const { progress } = useProgressBar(); + if (!progress) return <>; + return ( + + + + ); +}; + +export default ProgressBar; diff --git a/frontend/src/units/progress/ProgressBarProvider.tsx b/frontend/src/units/progress/ProgressBarProvider.tsx new file mode 100644 index 0000000..dcf445e --- /dev/null +++ b/frontend/src/units/progress/ProgressBarProvider.tsx @@ -0,0 +1,65 @@ +import { isArray } from "lodash"; +import React, { createContext, useCallback, useMemo, useState } from "react"; + +export type ProgressBarUnit = { key: string | null; acting: boolean }; + +type ProgressBarContextPayload = { + progress: boolean; + actions: { + add: (key: string) => void; + remove: (key: string) => void; + observe: (unit?: ProgressBarUnit | ProgressBarUnit[]) => void; + }; +}; + +const initialContext: ProgressBarContextPayload = { + progress: false, + actions: { + add: () => undefined, + remove: () => undefined, + observe: () => undefined + } +}; + +const ProgressBarContext = createContext(initialContext); + +type Props = { children: React.ReactNode }; +const ProgressBarProvider: React.FC = ({ children }) => { + const [network, setNetwork] = useState([]); + const handleAdd = useCallback((key: string) => setNetwork(prev => [...prev, key]), []); + const handleRemove = useCallback((key: string) => setNetwork(prev => prev.filter(z => z !== key)), []); + + const handleObserve = useCallback( + (unit?: ProgressBarUnit | ProgressBarUnit[]) => { + const items: ProgressBarUnit[] = []; + + if (unit) { + if (isArray(unit)) { + items.push(...unit); + } else { + items.push(unit); + } + } + + for (const z of items) { + if (!z.key) continue; + if (z.acting) { + handleAdd(z.key); + } else { + handleRemove(z.key); + } + } + }, + [handleAdd, handleRemove] + ); + + const progress = useMemo(() => network.length > 0, [network.length]); + const payload = useMemo( + () => ({ progress, actions: { add: handleAdd, remove: handleRemove, observe: handleObserve } }), + [progress, handleAdd, handleRemove, handleObserve] + ); + return {children}; +}; + +export { ProgressBarContext }; +export default ProgressBarProvider; diff --git a/frontend/src/units/progress/hooks.ts b/frontend/src/units/progress/hooks.ts new file mode 100644 index 0000000..e4d45e9 --- /dev/null +++ b/frontend/src/units/progress/hooks.ts @@ -0,0 +1,14 @@ +import { useContext } from "react"; +import { ProgressBarContext, ProgressBarUnit } from "./ProgressBarProvider"; + +type UseProgressBarResult = { + progress: boolean; + observe: (unit?: ProgressBarUnit | ProgressBarUnit[]) => void; +}; + +const useProgressBar = (): UseProgressBarResult => { + const context = useContext(ProgressBarContext); + return { progress: context.progress, observe: context.actions.observe }; +}; + +export { useProgressBar }; diff --git a/frontend/src/units/progress/index.ts b/frontend/src/units/progress/index.ts new file mode 100644 index 0000000..c206efa --- /dev/null +++ b/frontend/src/units/progress/index.ts @@ -0,0 +1,4 @@ +import ProgressBarProvider from "./ProgressBarProvider"; +import ProgressBar from "./ProgressBar"; +export { ProgressBarProvider, ProgressBar }; +export * from "./hooks"; diff --git a/frontend/src/units/swr/errors.ts b/frontend/src/units/swr/errors.ts new file mode 100644 index 0000000..368b2d4 --- /dev/null +++ b/frontend/src/units/swr/errors.ts @@ -0,0 +1,16 @@ +export type ServerError = { + title: string; + status: number; + message: string | null; +}; + +export class NetworkError extends Error { + status: number; + serverError: ServerError; + + constructor(message: string, status: number, serverError: ServerError) { + super(message); + this.status = status; + this.serverError = serverError; + } +} diff --git a/frontend/src/units/swr/fetchers.ts b/frontend/src/units/swr/fetchers.ts new file mode 100644 index 0000000..bec9af1 --- /dev/null +++ b/frontend/src/units/swr/fetchers.ts @@ -0,0 +1,56 @@ +import i18next from "i18next"; +import { acquire as fetchTuitioData } from "@flare/tuitio-client"; +import { NetworkError, ServerError } from "./errors"; + +const getHeaders = (): HeadersInit => { + const { token } = fetchTuitioData(); + const language = i18next.language; + + const headers: HeadersInit = { + "Content-Type": "application/json" + }; + + if (token) { + headers.Authorization = `Tuitio ${token}`; + } + + if (language) { + headers["Accept-Language"] = language; + } + + return headers; +}; + +const fetcher = async (url: string) => { + const res = await fetch(url, { method: "GET", headers: getHeaders() }); + + if (!res.ok) { + const serverError = (await res.json()) as ServerError; + const error = new NetworkError("An error occurred while fetching the data.", res.status, serverError); + throw error; + } + + return res.json(); +}; + +async function mutationFetcher(url: string, { arg }: { arg: Command }) { + const hasBody = arg !== undefined && arg !== null; + const headers = getHeaders(); + const body = hasBody ? JSON.stringify(arg) : undefined; + + const res = await fetch(url, { + method: "POST", + headers, + body + }); + + if (!res.ok) { + const serverError = (await res.json()) as ServerError; + const error = new NetworkError("An error occurred while mutating the data.", res.status, serverError); + throw error; + } + + return res.json(); +} + +export { fetcher, mutationFetcher }; diff --git a/frontend/src/units/swr/hooks/index.ts b/frontend/src/units/swr/hooks/index.ts new file mode 100644 index 0000000..d49e535 --- /dev/null +++ b/frontend/src/units/swr/hooks/index.ts @@ -0,0 +1,3 @@ +import useGuardedMutation from "./useGuardedMutation"; + +export { useGuardedMutation }; diff --git a/frontend/src/units/swr/hooks/useGuardedMutation.ts b/frontend/src/units/swr/hooks/useGuardedMutation.ts new file mode 100644 index 0000000..c2f0897 --- /dev/null +++ b/frontend/src/units/swr/hooks/useGuardedMutation.ts @@ -0,0 +1,39 @@ +import useSWRMutation, { MutationFetcher, SWRMutationConfiguration, SWRMutationResponse } from "swr/mutation"; +import { Key, NetworkError } from "../../swr"; +import { blip } from "utils"; +import { useMemo } from "react"; + +const defaultErrorHandler = (error: Error) => { + const isNetworkError = error instanceof NetworkError; + if (isNetworkError) { + blip.error(error.serverError.message || error.message); + return; + } + blip.error(error.message); +}; + +function useGuardedMutation< + Data = any, + Error = any, + SWRMutationKey extends Key = Key, + ExtraArg = never, + SWRData = Data +>( + key: SWRMutationKey, + fetcher: MutationFetcher, + options?: SWRMutationConfiguration & { throwOnError?: boolean } +): SWRMutationResponse { + const opts = useMemo( + () => + ({ + ...options, + onError: options?.onError ?? defaultErrorHandler, + throwOnError: options?.throwOnError ?? false + } as SWRMutationConfiguration), + [options] + ); + + return useSWRMutation(key, fetcher, opts); +} + +export default useGuardedMutation; diff --git a/frontend/src/units/swr/index.ts b/frontend/src/units/swr/index.ts new file mode 100644 index 0000000..0480844 --- /dev/null +++ b/frontend/src/units/swr/index.ts @@ -0,0 +1,10 @@ +import useSWR from "swr"; +import type { Key } from "swr"; +import useSWRMutation from "swr/mutation"; + +export * from "./fetchers"; +export * from "./errors"; +export * from "./hooks"; + +export { useSWR, useSWRMutation }; +export type { Key }; diff --git a/frontend/src/themes/defaults.ts b/frontend/src/units/themes/defaults.ts similarity index 100% rename from frontend/src/themes/defaults.ts rename to frontend/src/units/themes/defaults.ts diff --git a/frontend/src/themes/index.ts b/frontend/src/units/themes/index.ts similarity index 100% rename from frontend/src/themes/index.ts rename to frontend/src/units/themes/index.ts diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js deleted file mode 100644 index 08dd64b..0000000 --- a/frontend/src/utils/api.js +++ /dev/null @@ -1,67 +0,0 @@ -import * as axios from "../utils/axios"; -import { toast } from "react-toastify"; -import env from "../utils/env"; - -const networkRoute = `${env.REACT_APP_NETWORK_RESURRECTOR_API_URL}/network`; -const systemRoute = `${env.REACT_APP_NETWORK_RESURRECTOR_API_URL}/system`; -const powerActionsRoute = `${env.REACT_APP_NETWORK_RESURRECTOR_API_URL}/resurrector`; -const securityRoute = `${env.REACT_APP_NETWORK_RESURRECTOR_API_URL}/security`; - -const routes = { - permissions: `${securityRoute}/permissions`, - systemVersion: `${systemRoute}/version`, - releaseNotes: `${systemRoute}/release-notes`, - resetCache: `${systemRoute}/reset-cache`, - machines: `${networkRoute}/machines`, - wakeMachine: `${powerActionsRoute}/wake`, - pingMachine: `${powerActionsRoute}/ping`, - shutdownMachine: `${powerActionsRoute}/shutdown`, - restartMachine: `${powerActionsRoute}/restart` -}; - -const handleError = err => { - let message; - switch (err?.status) { - case 500: - message = err.title; - break; - - case 404: - message = err.message; - break; - - default: - message = err.title; - } - - toast.error(message); -}; - -const defaultOptions = { - onCompleted: () => null, - onError: handleError -}; - -const call = async (request, options = defaultOptions) => { - const internalOptions = { ...defaultOptions, ...options }; - const { onCompleted, onError } = internalOptions; - - try { - const result = await request(); - onCompleted(result); - } catch (error) { - onError(error); - } -}; - -const get = (route, options) => { - const promise = call(() => axios.get(route), options); - return promise; -}; - -const post = (route, data, options) => { - const promise = call(() => axios.post(route, data), options); - return promise; -}; - -export { routes, get, post }; diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts new file mode 100644 index 0000000..a218992 --- /dev/null +++ b/frontend/src/utils/api.ts @@ -0,0 +1,30 @@ +import env from "../utils/env"; + +const apiHost = env.REACT_APP_NETWORK_RESURRECTOR_API_URL; + +const networkRoute = `${apiHost}/network`; +const systemRoute = `${apiHost}/system`; +const resurrectorRoute = `${apiHost}/resurrector`; +const securityRoute = `${apiHost}/security`; + +const endpoints = { + network: { + machines: `${networkRoute}/machines`, + machine: { + wake: `${resurrectorRoute}/wake`, + ping: `${resurrectorRoute}/ping`, + shutdown: `${resurrectorRoute}/shutdown`, + restart: `${resurrectorRoute}/restart` + } + }, + system: { + version: `${systemRoute}/version`, + releaseNotes: `${systemRoute}/release-notes`, + resetCache: `${systemRoute}/reset-cache` + }, + security: { + permissions: `${securityRoute}/permissions` + } +}; + +export { endpoints }; diff --git a/frontend/src/utils/axios.js b/frontend/src/utils/axios.js deleted file mode 100644 index eb73c74..0000000 --- a/frontend/src/utils/axios.js +++ /dev/null @@ -1,74 +0,0 @@ -import axios from "axios"; -import i18next from "i18next"; -import { acquire as fetchTuitioData } from "@flare/tuitio-client"; - -function getHeaders() { - const { token } = fetchTuitioData(); - const language = i18next.language; - - return { - "Content-Type": "application/json", - Authorization: `Tuitio ${token}`, - "Accept-Language": `${language}` - }; -} - -async function internalRequest(url, options) { - try { - const res = await axios.request(url, options); - return res.data; - } catch (error) { - if (error.response && error.response.data) { - throw ( - { - ...error.response.data, - message: error.response.data.detail || error.response.data.title - } || error - ); - } - // The request was made but no response was received - // `error.request` is an instance of XMLHttpRequest in the browser and an instance of - // http.ClientRequest in node.js - throw error; - } -} - -export const request = (url, options) => internalRequest(url, options); - -export function post(url, data) { - const options = { - method: "post", - data: JSON.stringify(data), - headers: getHeaders() - }; - - return internalRequest(url, options); -} - -export function put(url, data) { - const options = { - method: "put", - data: JSON.stringify(data), - headers: getHeaders() - }; - - return internalRequest(url, options); -} - -export function del(url, data) { - const options = { - method: "delete", - data: JSON.stringify(data), - headers: getHeaders() - }; - - return internalRequest(url, options); -} - -export function get(url) { - const options = { - method: "GET", - headers: getHeaders() - }; - return internalRequest(url, options); -} diff --git a/frontend/src/utils/toast.js b/frontend/src/utils/blip.js similarity index 75% rename from frontend/src/utils/toast.js rename to frontend/src/utils/blip.js index 4045135..82c7f0e 100644 --- a/frontend/src/utils/toast.js +++ b/frontend/src/utils/blip.js @@ -6,4 +6,6 @@ const warning = message => toast.warning(message); const error = message => toast.error(message); const dark = message => toast.dark(message); -export { info, success, warning, error, dark }; +const blip = { info, success, warning, error, dark }; +export { blip }; +export default blip; diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js deleted file mode 100644 index 0dbd747..0000000 --- a/frontend/src/utils/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { getRandomElement } from "./random"; - -export { getRandomElement }; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts new file mode 100644 index 0000000..2115555 --- /dev/null +++ b/frontend/src/utils/index.ts @@ -0,0 +1,4 @@ +import { getRandomElement } from "./random"; +import blip from "./blip"; + +export { getRandomElement, blip };