diff --git a/app/.env.example b/app/.env.example deleted file mode 100644 index 3bd3bcd..0000000 --- a/app/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# This is an example file of what your .env may look like. -# You do not need to make a .env file unless you need to -# change some of these values in your environment. - -# This file will NOT be loaded by Vite and is purely for -# example purposes. Create a new file titled ".env" or -# ".env.". - -# The WebSocket IP to use to connect to ROS. -# Default: "ws://127.0.0.1:9090". -VITE_ROS_IP=ws://127.0.0.1:9090 diff --git a/app/config/main.yaml b/app/config/main.yaml new file mode 100644 index 0000000..7770ea5 --- /dev/null +++ b/app/config/main.yaml @@ -0,0 +1,3 @@ +# The WebSocket IP to use to connect to ROS. +# Default: "ws://127.0.0.1:9090". +rosIp: "ws://127.0.0.1:9090" diff --git a/app/package.json b/app/package.json index cdcd623..167a657 100644 --- a/app/package.json +++ b/app/package.json @@ -19,12 +19,16 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/inter": "^5.1.1", + "@modyfi/vite-plugin-yaml": "^1.1.1", "@mui/icons-material": "^6.4.5", "@mui/material": "^6.4.7", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", + "@types/js-yaml": "^4.0.9", + "ajv": "8", "date-fns": "^4.1.0", + "js-yaml": "^4.1.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/app/public/fonts/CascadiaCode/CascadiaCode.woff2 b/app/public/fonts/CascadiaCode/CascadiaCode.woff2 new file mode 100644 index 0000000..8e6778a Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaCode.woff2 differ diff --git a/app/public/fonts/CascadiaCode/CascadiaCodeItalic.woff2 b/app/public/fonts/CascadiaCode/CascadiaCodeItalic.woff2 new file mode 100644 index 0000000..ed8477b Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaCodeItalic.woff2 differ diff --git a/app/public/fonts/CascadiaCode/CascadiaCodeNF.woff2 b/app/public/fonts/CascadiaCode/CascadiaCodeNF.woff2 new file mode 100644 index 0000000..56b6c99 Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaCodeNF.woff2 differ diff --git a/app/public/fonts/CascadiaCode/CascadiaCodeNFItalic.woff2 b/app/public/fonts/CascadiaCode/CascadiaCodeNFItalic.woff2 new file mode 100644 index 0000000..571613f Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaCodeNFItalic.woff2 differ diff --git a/app/public/fonts/CascadiaCode/CascadiaCodePL.woff2 b/app/public/fonts/CascadiaCode/CascadiaCodePL.woff2 new file mode 100644 index 0000000..afc5e80 Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaCodePL.woff2 differ diff --git a/app/public/fonts/CascadiaCode/CascadiaCodePLItalic.woff2 b/app/public/fonts/CascadiaCode/CascadiaCodePLItalic.woff2 new file mode 100644 index 0000000..b8c1622 Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaCodePLItalic.woff2 differ diff --git a/app/public/fonts/CascadiaCode/CascadiaMono.woff2 b/app/public/fonts/CascadiaCode/CascadiaMono.woff2 new file mode 100644 index 0000000..ca77005 Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaMono.woff2 differ diff --git a/app/public/fonts/CascadiaCode/CascadiaMonoItalic.woff2 b/app/public/fonts/CascadiaCode/CascadiaMonoItalic.woff2 new file mode 100644 index 0000000..d396032 Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaMonoItalic.woff2 differ diff --git a/app/public/fonts/CascadiaCode/CascadiaMonoNF.woff2 b/app/public/fonts/CascadiaCode/CascadiaMonoNF.woff2 new file mode 100644 index 0000000..57bd3a8 Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaMonoNF.woff2 differ diff --git a/app/public/fonts/CascadiaCode/CascadiaMonoNFItalic.woff2 b/app/public/fonts/CascadiaCode/CascadiaMonoNFItalic.woff2 new file mode 100644 index 0000000..0040b80 Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaMonoNFItalic.woff2 differ diff --git a/app/public/fonts/CascadiaCode/CascadiaMonoPL.woff2 b/app/public/fonts/CascadiaCode/CascadiaMonoPL.woff2 new file mode 100644 index 0000000..d7c5245 Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaMonoPL.woff2 differ diff --git a/app/public/fonts/CascadiaCode/CascadiaMonoPLItalic.woff2 b/app/public/fonts/CascadiaCode/CascadiaMonoPLItalic.woff2 new file mode 100644 index 0000000..9ce8f63 Binary files /dev/null and b/app/public/fonts/CascadiaCode/CascadiaMonoPLItalic.woff2 differ diff --git a/app/public/yes-yes-sir.gif b/app/public/yes-yes-sir.gif new file mode 100644 index 0000000..c6b2d20 Binary files /dev/null and b/app/public/yes-yes-sir.gif differ diff --git a/app/src/components/App.tsx b/app/src/components/App.tsx index 3443414..5efb296 100644 --- a/app/src/components/App.tsx +++ b/app/src/components/App.tsx @@ -1,21 +1,36 @@ import { Box, Grid2 as Grid } from "@mui/material"; import { useContext, useState } from "react"; +import { Status } from "../types/status"; import Console from "./Console/Console"; +import Dashboard from "./Dashboard/Dashboard"; import NavBar from "./NavBar/NavBar"; import NodeManager from "./NodeManager/NodeManager"; import { GlobalStatusContext } from "./Providers/ROSProvider"; -import "/src/css/App.css"; /** * The entry point of the program. */ function App() { const [selectedNode, setSelectedNode] = useState(null); - const { globalStatus, setGlobalStatus } = useContext(GlobalStatusContext); + const { globalStatusHistory, setGlobalStatusHistory } = + useContext(GlobalStatusContext); + + const mainSection = + selectedNode == null ? ( + + ) : ( + setSelectedNode(null)} + /> + ); return ( - + - setSelectedNode(null)} - /> + {mainSection} diff --git a/app/src/components/Console/Console.tsx b/app/src/components/Console/Console.tsx index a7430aa..ae9e60f 100644 --- a/app/src/components/Console/Console.tsx +++ b/app/src/components/Console/Console.tsx @@ -16,7 +16,6 @@ function Console({ ...props }: Console) { const consoleTitle = props.selectedNode ? `Console: ${props.selectedNode}` : "Console"; - const visibility = props.selectedNode ? undefined : { visibility: "hidden" }; const consoleOutputRef = useRef(null); const rawSocketHistory = useContext(WSHistoryContext); const filteredSocketHistory = rawSocketHistory[props.selectedNode || ""]; @@ -59,7 +58,7 @@ function Console({ ...props }: Console) { // This isn't perfect but it mostly works. if (!autoScroll && socketHistory.length === maxHistoryLength) { const { current } = consoleOutputRef; - current.scrollTop -= 14; // 14px represents roughly 1 line + current.scrollTop -= 12; // 12px represents roughly 1 line } }, [socketHistory, autoScroll]); @@ -97,9 +96,10 @@ function Console({ ...props }: Console) { return `[${format(msg.timestamp, "HH:mm:ss.SSS")}] [${msg.topic}] ${msg.message}`; }) .join("\n"); - if (text === "") + if (text === "") { text = "No messages received yet. Perhaps you should try changing your filters?"; + } if (socketHistory.length === maxHistoryLength) { text = `...history limited to ${maxHistoryLength} lines\n${text}`; } @@ -112,14 +112,15 @@ function Console({ ...props }: Console) { height: "inherit", display: "flex", flexDirection: "column", - borderRadius: "20px 0 0 0", + borderRadius: "12px 0 0 0", }} > - + {consoleTitle} @@ -134,17 +136,18 @@ function Console({ ...props }: Console) { - + -
+        
           {renderConsoleText()}
         
diff --git a/app/src/components/Console/ConsoleFilters.tsx b/app/src/components/Console/ConsoleFilters.tsx index 9c7844e..948c8c3 100644 --- a/app/src/components/Console/ConsoleFilters.tsx +++ b/app/src/components/Console/ConsoleFilters.tsx @@ -28,7 +28,12 @@ function ConsoleFilters({ ...props }: ConsoleFilters) { } if (enabledChips.length + disabledChips.length === 0) { - return ; + return ( + + ); } return [...enabledChips, ...disabledChips]; // so they are organized :) } @@ -46,7 +51,15 @@ function ConsoleFilters({ ...props }: ConsoleFilters) { disableTopic(topic)} onClick={() => disableTopic(topic)} @@ -57,7 +70,15 @@ function ConsoleFilters({ ...props }: ConsoleFilters) { enableTopic(topic)} /> @@ -97,10 +118,16 @@ function ConsoleFilters({ ...props }: ConsoleFilters) { return ( !prev); }} > - + ); } @@ -129,6 +156,7 @@ function ConsoleFilters({ ...props }: ConsoleFilters) { }, msOverflowStyle: "none", // IE and Edge whiteSpace: "nowrap", // Keeps content in a single line + py: 0.5, }} > {renderToggleShowButton()} diff --git a/app/src/components/Dashboard/CurrentTask.tsx b/app/src/components/Dashboard/CurrentTask.tsx new file mode 100644 index 0000000..0628d1f --- /dev/null +++ b/app/src/components/Dashboard/CurrentTask.tsx @@ -0,0 +1,74 @@ +import { Typography } from "@mui/material"; +import { useContext, useState } from "react"; +import { formatTimeDifference } from "../../util/util"; +import { GlobalStatusContext } from "../Providers/ROSProvider"; +import DashboardCard from "./DashboardCard"; +import TaskHistoryDialog from "./TaskHistoryDialog"; + +/** + * "Current Task" section of the dashboard, displays the extended + * status of the latest status message (or "Unknown if that is + * undefined"). The latest status should be the last item in the + * `globalStatusHistory` array. + */ +function CurrentTask() { + const { globalStatusHistory } = useContext(GlobalStatusContext); + const [historyDialogOpen, setHistoryDialogOpen] = useState(false); + + const handleOpenHistory = () => { + setHistoryDialogOpen(true); + }; + + const handleCloseHistory = () => { + setHistoryDialogOpen(false); + }; + + const prevTasks = globalStatusHistory.length - 1; + + return ( + <> + + + {prevTasks} previous task + {/* Make plural when prevTasks != 1 */} + {prevTasks !== 1 && "s"} + + + {globalStatusHistory.at(-1)?.extendedStatus ?? "Unknown"} ( + {!!globalStatusHistory.at(-1)?.timestamp && + formatTimeDifference( + globalStatusHistory.at(-1)!.timestamp, + new Date(), + )} + ) + + + + + + ); +} + +export default CurrentTask; diff --git a/app/src/components/Dashboard/Dashboard.tsx b/app/src/components/Dashboard/Dashboard.tsx new file mode 100644 index 0000000..3c2f1e1 --- /dev/null +++ b/app/src/components/Dashboard/Dashboard.tsx @@ -0,0 +1,92 @@ +import { Avatar, Box, Grid2, Paper, Stack, Typography } from "@mui/material"; +import type { Dashboard } from "../../types/dashboard"; +import EStop from "../NavBar/EStop"; +import CurrentTask from "./CurrentTask"; +import NodeHealth from "./NodeHealth"; +import RunningTime from "./RunningTime"; + +/** + * The Dashboard component renders the main layout for the application dashboard. + * It displays key information such as node health, current task, running time, + * a large E-Stop button, and a fun graphic. + * It organizes these elements into a two-column layout. + * + * @returns The rendered Dashboard component. + */ +function Dashboard({ ...props }: Dashboard) { + return ( + + + + Dashboard + + + + + + + + + + + + + + + + + + + + + ); +} + +export default Dashboard; diff --git a/app/src/components/Dashboard/DashboardCard.tsx b/app/src/components/Dashboard/DashboardCard.tsx new file mode 100644 index 0000000..6d58063 --- /dev/null +++ b/app/src/components/Dashboard/DashboardCard.tsx @@ -0,0 +1,86 @@ +import { ChevronRightRounded } from "@mui/icons-material"; +import { Box, ButtonBase, Chip, Paper, Stack, Typography } from "@mui/material"; +import { ReactNode } from "react"; + +interface DashboardCardProps { + title: string; + children: ReactNode; + action?: DashboardCardAction; +} + +interface DashboardCardAction { + onClick: () => void; + label: string; +} + +/** + * Reusable dashboard card component with consistent styling. + * + * @param props.title - The title for the card + * @param props.children - The content to display in the card + */ +function DashboardCard({ title, children, action }: DashboardCardProps) { + const paperContent = ( + + + + {title} + + {action && ( + } // hack for right-aligned icon + onDelete={() => {}} // hack for right-aligned icon + onClick={action.onClick} + sx={{ fontSize: "0.75rem", height: 24, pl: 0.5, pr: 0.3 }} + /> + )} + + {children} + + ); + + // If action is provided, wrap the paper content in a button base (whole card acts as a button) + if (action) { + return ( + + {paperContent} + + ); + } + + return paperContent; +} + +export default DashboardCard; diff --git a/app/src/components/Dashboard/NodeHealth.tsx b/app/src/components/Dashboard/NodeHealth.tsx new file mode 100644 index 0000000..62ee584 --- /dev/null +++ b/app/src/components/Dashboard/NodeHealth.tsx @@ -0,0 +1,111 @@ +import { Box, Typography } from "@mui/material"; +import { differenceInMilliseconds } from "date-fns"; +import { useContext, useEffect, useState } from "react"; +import { isRunning } from "../../util/status"; +import { + DiscoveredNodesContext, + WSHistoryContext, +} from "../Providers/ROSProvider"; +import DashboardCard from "./DashboardCard"; + +/** + * The "Node Health" section of the dashboard; displays information about how many nodes + * there are, and how many topics there are. + */ +function NodeHealth() { + const nodeData = useContext(DiscoveredNodesContext); + const nodeHistory = useContext(WSHistoryContext); + const [_, setUpdate] = useState({}); // dummy state to force update + const now = new Date(); + + let nodeHealthData = { + discovered: 0, + running: 0, + stopped: 0, + topics: new Set(), + }; + + for (const nodeId in nodeData) { + const node = nodeData[nodeId]; + nodeHealthData.discovered++; + + // Check if nodes are running/stopped + if (isRunning(node.status)) { + nodeHealthData.running++; + } else { + nodeHealthData.stopped++; + } + + // Count topics + for (const topic of node.publishers) { + nodeHealthData.topics.add(topic.topic); + } + for (const topic of node.subscribers) { + // in case there are things we don't see + nodeHealthData.topics.add(topic.topic); + } + } + + // Calculate messages per second + let mps = 0; + for (const nodeId in nodeHistory) { + const node = nodeHistory[nodeId]; + for (const topic in node) { + const msgs = node[topic]; + // Looping backwards (newest at end of array) + for (let i = msgs.length - 1; i >= 0; i--) { + const rawMsg = msgs[i]; + if (differenceInMilliseconds(now, rawMsg.timestamp) > 1000) { + // breaking here stops processing messages older than 1 second. + break; + } + mps++; + } + } + } + + // Updates the state so that mps will still be recalculated if no new messages + // come in for more than 100ms + useEffect(() => { + let intervalId: number | undefined; + if (mps > 0) { + intervalId = setInterval(() => { + setUpdate(new Object()); + }, 100); + } + return () => { + if (intervalId) { + clearInterval(intervalId); + } + }; + }, [nodeHistory]); + + return ( + + + {/* Discovered nodes text */} + + {nodeHealthData.discovered} discovered nodes + + + • {nodeHealthData.running} running + + + • {nodeHealthData.stopped} stopped + + + {/* Discovered topics text */} + + {nodeHealthData.topics.size} discovered topics + + + • {mps} msgs/sec + + + + ); +} + +export default NodeHealth; diff --git a/app/src/components/Dashboard/RunningTime.tsx b/app/src/components/Dashboard/RunningTime.tsx new file mode 100644 index 0000000..705dd56 --- /dev/null +++ b/app/src/components/Dashboard/RunningTime.tsx @@ -0,0 +1,51 @@ +import { Typography } from "@mui/material"; +import { useContext } from "react"; +import { Status } from "../../types/status"; +import { isRunning } from "../../util/status"; +import { GlobalStatusContext } from "../Providers/ROSProvider"; +import DashboardCard from "./DashboardCard"; +import RunningTimeClock from "./RunningTimeClock"; + +/** + * The "Running Time" section of the dashboard; contains some dates to show when the bot started + * and how long it's been running for. + */ +function RunningTime() { + const { globalStatusHistory } = useContext(GlobalStatusContext); + const now = new Date(); + + function getLastRunningTimestamp() { + // Check the earliest state it was running in since the last time it was stopped + let earliestRunningIndex = 0; // negatively indexed + while ( + isRunning( + globalStatusHistory.at(earliestRunningIndex - 1)?.status ?? + Status.Unknown, + ) + ) { + earliestRunningIndex--; + } + + // If it's 0 then it's currently stopped + if (earliestRunningIndex === 0) { + return; + } + + return globalStatusHistory.at(earliestRunningIndex)?.timestamp; + } + const lastRunningTimestamp = getLastRunningTimestamp(); + + return ( + + {lastRunningTimestamp !== undefined ? ( + + ) : ( + + Not Running + + )} + + ); +} + +export default RunningTime; diff --git a/app/src/components/Dashboard/RunningTimeClock.tsx b/app/src/components/Dashboard/RunningTimeClock.tsx new file mode 100644 index 0000000..44fc32a --- /dev/null +++ b/app/src/components/Dashboard/RunningTimeClock.tsx @@ -0,0 +1,50 @@ +import { Typography } from "@mui/material"; +import { formatDate } from "date-fns"; +import { useEffect, useState } from "react"; +import type { RunningTimeClock } from "../../types/dashboard"; +import { formatTimeDifference } from "../../util/util"; + +/** + * Helper component for RunningTime to abstract date formatting logic and + * also so that when I re-render the component to update the time it doesn't + * go looping through to find the latest timestamp each time. + * + * @param props.timestamp The timestamp representing the time + * @returns Text containing a timestamp of the running time and how long it's + * been running for. + */ +function RunningTimeClock({ ...props }: RunningTimeClock) { + const [now, setNow] = useState(new Date()); + + // Format the starting timestamp + const formattedTimestamp = formatDate(props.timestamp, "HH:mm:ss"); + + // Calculate + format the duration between the start date (props.timestamp) and the end date (now) + const timeDistance = formatTimeDifference(props.timestamp, now); + + // Update `now` every 100ms + useEffect(() => { + // Interval to update in 100ms (if I did every 1000ms it could be up to a second off) + const intervalId = setInterval(() => { + setNow(new Date()); + }, 100); + + // Clear interval on component refresh + return () => { + clearInterval(intervalId); + }; + }, [props.timestamp, now]); + + return ( + <> + + Started {formattedTimestamp} + + + Running for {timeDistance} + + + ); +} + +export default RunningTimeClock; diff --git a/app/src/components/Dashboard/TaskHistoryDialog.tsx b/app/src/components/Dashboard/TaskHistoryDialog.tsx new file mode 100644 index 0000000..53f384b --- /dev/null +++ b/app/src/components/Dashboard/TaskHistoryDialog.tsx @@ -0,0 +1,81 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from "@mui/material"; +import { formatDate } from "date-fns"; +import { + formatTimeDifference, + formatTimeDifferenceAbbreviated, +} from "../../util/util"; + +interface TaskHistoryDialogProps { + open: boolean; + onClose: () => void; + statusHistory: Array<{ + extendedStatus?: string; + timestamp: Date; + }>; +} + +function TaskHistoryDialog({ + open, + onClose, + statusHistory, +}: TaskHistoryDialogProps) { + const history = [...statusHistory].reverse(); + + return ( + + Task History ({history.length} tasks) + + {history.length === 0 ? ( + + No status history available + + ) : ( + + {history.map((status, index) => { + const key = `${status.timestamp.getUTCMilliseconds()}-${status.extendedStatus}`; + const prevStart = + index === 0 ? undefined : history[index - 1].timestamp; + return ( + + {status.extendedStatus} + + {index === 0 && + `Running for ${formatTimeDifference(status.timestamp, new Date())}`} + {prevStart && + `Ran for ${formatTimeDifferenceAbbreviated(status.timestamp, prevStart)}`}{" "} + (Started {formatDate(status.timestamp, "HH:mm:ss")}) + + + ); + })} + + )} + + + + + + ); +} + +export default TaskHistoryDialog; diff --git a/app/src/components/NavBar/EStop.tsx b/app/src/components/NavBar/EStop.tsx index ee39f78..3202438 100644 --- a/app/src/components/NavBar/EStop.tsx +++ b/app/src/components/NavBar/EStop.tsx @@ -1,42 +1,78 @@ import { Avatar, Button, Typography } from "@mui/material"; import { useContext } from "react"; import type { EStop } from "../../types/navbar"; -import { Status } from "../../types/status"; import { ROSCommunicationContext } from "../Providers/ROSProvider"; /** - * Emergency Stop button to immediately send a message to ROS and stop the robot. + * Emergency Stop button to immediately send a message to ROS to stop the robot. * - * @param props.setStatus A function to update the state if the status. + * @param props.style Whether the button should be large (just the "button") or + * small (a short but long style with text as well). */ function EStop({ ...props }: EStop) { const send = useContext(ROSCommunicationContext); function onClick() { send.publish("/hmi_start_stop", "stop"); - props.setStatus(Status.Stopped); } - return ( - - ); + if (props.style == "small") { + return ( + + ); + } + + if (props.style == "large") { + return ( + + ); + } } export default EStop; diff --git a/app/src/components/NavBar/NavBar.tsx b/app/src/components/NavBar/NavBar.tsx index 9698ab9..4efa7ec 100644 --- a/app/src/components/NavBar/NavBar.tsx +++ b/app/src/components/NavBar/NavBar.tsx @@ -7,8 +7,8 @@ import StatusSummary from "./StatusSummary"; /** * The upper navigation bar of the app. * - * @param props.status The current status of the robot. - * @param props.setStatus A function to update the state if the status. + * @param props.status The current status history. + * @param props.setStatus A function to update the status history state. * @returns */ function NavBar({ ...props }: NavBar) { @@ -19,18 +19,26 @@ function NavBar({ ...props }: NavBar) { gridTemplateColumns: "auto auto", // Two columns: left and right alignItems: "center", padding: "1rem", - backgroundColor: "background.surface", // Use Joy UI theme background + backgroundColor: "background.surface", }} > - {/* Logo */} - + {/* GRR Logo */} + GRR-inator - + diff --git a/app/src/components/NavBar/StartButton.tsx b/app/src/components/NavBar/StartButton.tsx index 4b72794..84ca51c 100644 --- a/app/src/components/NavBar/StartButton.tsx +++ b/app/src/components/NavBar/StartButton.tsx @@ -1,7 +1,8 @@ import { Check, PlayArrow } from "@mui/icons-material"; import { IconButton } from "@mui/material"; -import { useContext, useEffect, useState } from "react"; +import { useContext } from "react"; import type { StartButton } from "../../types/navbar"; +import { GlobalStatus } from "../../types/rosProvider"; import { Status } from "../../types/status"; import { isRunning } from "../../util/status"; import { ROSCommunicationContext } from "../Providers/ROSProvider"; @@ -14,43 +15,23 @@ import { ROSCommunicationContext } from "../Providers/ROSProvider"; */ function StartButton({ ...props }: StartButton) { const send = useContext(ROSCommunicationContext); - const [wait, setWait] = useState(-1); // until proper implementation - const [disabled, setDisabled] = useState( - isRunning(props.status) || props.status === Status.Unknown, - ); + const disabled = isRunning(props.status) || props.status === Status.Unknown; + function onClick() { - props.setStatus(Status.Loading); + const loading: GlobalStatus = { + timestamp: new Date(), + status: Status.Loading, + extendedStatus: "Sending start message to ROS...", // Current task message + }; + + // Add new status to the history + props.setStatus((prev) => [...prev, loading]); send.publish("/hmi_start_stop", "start"); - setDisabled(true); - setWait(5); // until proper implementation } - /** - * Temporary delay until this is handled in ROS. - */ - useEffect(() => { - if (wait === 0) { - if (props.status === Status.Loading) { - props.setStatus(Status.OK); - setWait(-1); - } - return; - } - if (wait < 0) { - setDisabled(isRunning(props.status) || props.status === Status.Unknown); - return; - } - - setTimeout(() => { - setWait((prev) => prev - 1); - }, 1000); - }, [wait, props.status]); - return ( -1 || props.status === Status.Unknown - ? { loading: true } - : undefined)} + {...(props.status === Status.Unknown ? { loading: true } : undefined)} onClick={onClick} disabled={disabled} sx={{ diff --git a/app/src/components/NavBar/StatusSummary.tsx b/app/src/components/NavBar/StatusSummary.tsx index 5f49307..0f354ed 100644 --- a/app/src/components/NavBar/StatusSummary.tsx +++ b/app/src/components/NavBar/StatusSummary.tsx @@ -12,12 +12,13 @@ function StatusSummary({ ...props }: StatusSummary) { Status: {props.status} diff --git a/app/src/components/NavBar/__tests__/EStop.test.tsx b/app/src/components/NavBar/__tests__/EStop.test.tsx index 2940de0..81ff1e1 100644 --- a/app/src/components/NavBar/__tests__/EStop.test.tsx +++ b/app/src/components/NavBar/__tests__/EStop.test.tsx @@ -1,6 +1,7 @@ import { act, cleanup, render } from "@testing-library/react"; import WS from "jest-websocket-mock"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import config from "../../../util/config"; import ROSProvider from "../../Providers/ROSProvider"; import { ExposeROSProvider } from "../../Providers/__tests__/ExposeROSProvider"; import EStop from "../EStop"; @@ -38,7 +39,7 @@ describe("EStop", () => { }); beforeEach(() => { - mockServer = new WS("ws://127.0.0.1:9090"); + mockServer = new WS(config.rosIp); }); it("Sends a stop message when clicked", async () => { @@ -46,7 +47,7 @@ describe("EStop", () => { const html = render( - + , ); await mockServer.connected; @@ -59,7 +60,6 @@ describe("EStop", () => { expect(mockServer.messages.length).toBeGreaterThan(0); }); - expect(setMockStatus).toHaveBeenCalled(); expect(mockServer.messages).toContain( JSON.stringify({ op: "publish", diff --git a/app/src/components/NavBar/__tests__/StartButton.test.tsx b/app/src/components/NavBar/__tests__/StartButton.test.tsx index c1ad10a..a3c0ab3 100644 --- a/app/src/components/NavBar/__tests__/StartButton.test.tsx +++ b/app/src/components/NavBar/__tests__/StartButton.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, render } from "@testing-library/react"; import WS from "jest-websocket-mock"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Status } from "../../../types/status"; +import config from "../../../util/config"; import ROSProvider from "../../Providers/ROSProvider"; import { ExposeROSProvider } from "../../Providers/__tests__/ExposeROSProvider"; import StartButton from "../StartButton"; @@ -39,7 +40,7 @@ describe("StartButton", () => { }); beforeEach(() => { - mockServer = new WS("ws://127.0.0.1:9090"); + mockServer = new WS(config.rosIp); }); it("Sends a start message when clicked", async () => { diff --git a/app/src/components/NodeManager/NodeItem.tsx b/app/src/components/NodeManager/NodeItem.tsx index 212c65a..7d71314 100644 --- a/app/src/components/NodeManager/NodeItem.tsx +++ b/app/src/components/NodeManager/NodeItem.tsx @@ -17,7 +17,7 @@ function NodeItem({ ...props }: NodeItem) { if (selected) { selectedStatusBorder = { border: "2px solid", - borderColor: "#000", + borderColor: "divider", borderRadius: 25, }; } @@ -29,14 +29,13 @@ function NodeItem({ ...props }: NodeItem) { width: "100%", borderRadius: 25, border: "1px solid", - borderColor: selected ? "black" : "gray", - bgcolor: selected ? "#aaa" : "#eee", + borderColor: selected ? "primary.main" : "divider", + bgcolor: selected ? "primary.main" : "background.paper", + color: selected ? "primary.contrastText" : "text.primary", textTransform: "none", // Prevents text from being transformed to uppercase - color: "inherit", // Keeps the original text color "&:hover": { - // Maintain colors on hover - color: "inherit", - borderColor: "inherit", + bgcolor: selected ? "primary.dark" : "action.hover", + borderColor: "primary.main", }, }} > @@ -64,17 +63,23 @@ function NodeItem({ ...props }: NodeItem) { - + diff --git a/app/src/components/NodeManager/NodeManager.tsx b/app/src/components/NodeManager/NodeManager.tsx index ceb4e40..90beaf1 100644 --- a/app/src/components/NodeManager/NodeManager.tsx +++ b/app/src/components/NodeManager/NodeManager.tsx @@ -1,7 +1,6 @@ import { Stack, Typography } from "@mui/material"; import { ReactElement, useContext } from "react"; import type { NodeManager } from "../../types/nodeManager"; -import { Status } from "../../types/status"; import { DiscoveredNodesContext } from "../Providers/ROSProvider"; import NodeItem from "./NodeItem"; @@ -23,7 +22,7 @@ function NodeManager({ ...props }: NodeManager) { selection={props.selectedNode} name={node} key={node} - status={Status.OK} + status={nodeData[node].status} />, ); } diff --git a/app/src/components/Providers/ROSProvider.tsx b/app/src/components/Providers/ROSProvider.tsx index 8fc26ef..b6636b4 100644 --- a/app/src/components/Providers/ROSProvider.tsx +++ b/app/src/components/Providers/ROSProvider.tsx @@ -2,6 +2,7 @@ import { createContext, useEffect, useRef, useState } from "react"; import type { DiscoveredNodes, DiscoveredTopic, + GlobalStatus, GlobalStatusContextType, RosCommunicationContext, RosMessage, @@ -13,13 +14,15 @@ import type { WSHistory, } from "../../types/rosProvider"; import { Status } from "../../types/status"; +import config from "../../util/config"; +import { isRunning } from "../../util/status"; import { deepCombineObjects } from "../../util/util"; export const WSHistoryContext = createContext({}); export const DiscoveredNodesContext = createContext({}); export const GlobalStatusContext = createContext({ - globalStatus: Status.Unknown, - setGlobalStatus: () => {}, + globalStatusHistory: [], + setGlobalStatusHistory: () => {}, }); export const ROSCommunicationContext = createContext({ sendRaw: () => {}, @@ -33,10 +36,18 @@ export const ROSCommunicationContext = createContext({ * A global state provider for subscribing to ROS topics and keeping track of received data. */ function ROSProvider({ ...props }) { - const rosIP: string = import.meta.env.VITE_ROS_IP || "ws://127.0.0.1:9090"; + const rosIP = config.rosIp; const wsRef = useRef(null); const [wsHistory, setWSHistory] = useState({}); - const [globalStatus, setGlobalStatus] = useState(Status.Unknown); + const [globalStatusHistory, setGlobalStatusHistory] = useState< + GlobalStatus[] + >([ + { + timestamp: new Date(), + status: Status.Unknown, + extendedStatus: "Loading...", + }, + ]); const [lastReceived, setLastReceived] = useState(new Date()); const timeoutPollRate = 10000; // milliseconds; we should be receiving data from ROS every ~5 seconds at minimum @@ -126,8 +137,8 @@ function ROSProvider({ ...props }) { `Error parsing data. Data: '${JSON.stringify(event.data)}'`, ); } - console.log(data); + // Handlers based on the operation type if (data.op === "service_response") { handleServiceResponse(data, timestamp); } else if (data.op === "publish") { @@ -150,8 +161,11 @@ function ROSProvider({ ...props }) { timestamp, }; - if (data.topic == "/node_info_pub") { + // Topic handlers + if (data.topic === "/node_info_pub") { checkNodeUpdates(message); + } else if (data.topic === "/hmi_start_stop") { + handleHmiStartStop(message); } setWSHistory((prev) => { @@ -190,13 +204,25 @@ function ROSProvider({ ...props }) { } /** - * Updates nodes based on nodes and topics received from /node_info_pub + * Processes updates containing ROS node and topic information. + * Discovers new nodes and topics, updates internal state, and subscribes to new publishers. * - * @param update The message from /node_info_pub or data from + * @param update The message from the node info source. Expected JSON payload in `update.message`. + * @throws Error If the message payload cannot be parsed as JSON. */ function checkNodeUpdates(update: RosMessage) { - if (globalStatus === Status.Unknown) setGlobalStatus(Status.Stopped); + // Initialize global status if it's unknown + if (globalStatusHistory.at(-1)?.status === Status.Unknown) + setGlobalStatusHistory((prev) => { + const stopped: GlobalStatus = { + timestamp: new Date(), + status: Status.Stopped, + extendedStatus: "N/A", + }; + return [...prev, stopped]; + }); + // Attempt to parse the incoming message string as JSON let data: RosNodeInfo; try { data = JSON.parse(update.message.toString()); @@ -206,17 +232,23 @@ function ROSProvider({ ...props }) { ); } + // Create a set of nodes present in this update to track which ones are still online + const activeNodes = new Set(Object.keys(data)); + let newDiscovered: DiscoveredNodes = {}; for (const node in data) { + // Check if this node is completely new (not in our discoveredNodes state) if (!Object.hasOwn(discoveredNodes, node)) { - newDiscovered[node] = data[node]; + newDiscovered[node] = { status: Status.OK, ...data[node] }; continue; } + // If the node already exists, check for new publishers on this node const pubUpdates = data[node].publishers; const discoveredPubs = discoveredNodes[node].publishers; let newDiscoveredPubs: DiscoveredTopic[] = []; for (const publisher of pubUpdates) { + // Check if this specific publisher topic is already known for this node let found = false; for (const discoveredPub of discoveredPubs) { if (discoveredPub.topic === publisher.topic) { @@ -224,7 +256,9 @@ function ROSProvider({ ...props }) { break; } } + if (!found) { + // If the publisher topic was not found in the discovered list, it's new newDiscoveredPubs.push({ topic: publisher.topic, type: publisher.type, @@ -232,10 +266,12 @@ function ROSProvider({ ...props }) { } } + // Now, check for new subscribers on this node (logic is similar to publishers) const subUpdates = data[node].subscribers; const discoveredSubs = discoveredNodes[node].subscribers; const newDiscoveredSubs: DiscoveredTopic[] = []; for (const subscriber of subUpdates) { + // Check if this specific subscriber topic is already known for this node let found = false; for (const discoveredSub of discoveredSubs) { if (discoveredSub.topic === subscriber.topic) { @@ -244,6 +280,7 @@ function ROSProvider({ ...props }) { } } if (!found) { + // If the subscriber topic was not found in the discovered list, it's new newDiscoveredSubs.push({ topic: subscriber.topic, type: subscriber.type, @@ -251,9 +288,13 @@ function ROSProvider({ ...props }) { } } + // If any new publishers or subscribers were found for this existing node if (newDiscoveredSubs.length !== 0 || newDiscoveredPubs.length !== 0) { const nodeData = data[node]; + // Add the node to newDiscovered, including *only* the new pubs/subs found. + // Service clients/servers are included from the latest update without diffing. newDiscovered[node] = { + status: Status.OK, publishers: newDiscoveredPubs, subscribers: newDiscoveredSubs, service_clients: nodeData.service_clients, @@ -262,12 +303,25 @@ function ROSProvider({ ...props }) { } } - // Nothing new found - if (Object.keys(newDiscovered).length === 0) return; - - // Add new things! + // Add new things and check for nodes that have gone offline setDiscoveredNodes((prev) => { let update: DiscoveredNodes = deepCombineObjects(prev, newDiscovered); + + // Check for nodes that went offline, and vice versa + for (const node in prev) { + if (isRunning(prev[node].status)) { + if (activeNodes.has(node)) continue; + + // This node was online but is not in the current update + update[node] = { ...update[node], status: Status.Stopped }; + } else { + if (!activeNodes.has(node)) continue; + + // This node was offline but now it is included again in the current update + update[node] = { ...update[node], status: Status.OK }; + } + } + return update; }); @@ -279,6 +333,59 @@ function ROSProvider({ ...props }) { } } + /** + * Processes start/stop updates. These will usually be sent out from StartButton, but + * this also allows for the ROS server to send messages on this topic to update the client + * status. + * + * @param update The message from the `/hmi_start_stop` topic. Expected JSON payload in + * `update.message`. + * @throws Error If the message payload cannot be parsed as JSON. + */ + function handleHmiStartStop(update: RosMessage) { + // Make sure this is a string + const msg = update.message as unknown; + if (typeof msg !== "string") { + console.warn( + "Warning: '/hmi_start_stop' message received that was not a string! Not processing.", + ); + console.warn("Message received:", msg); + return; + } + + // Here is where we will ignore it if server-side status is setup + + // Handle operations + switch (msg.toLowerCase()) { + case "start": + setGlobalStatusHistory((prev) => [ + ...prev, + { + timestamp: update.timestamp, + status: Status.OK, + extendedStatus: "Running", + }, + ]); + break; + case "stop": + setGlobalStatusHistory((prev) => [ + ...prev, + { + timestamp: update.timestamp, + status: Status.Stopped, + extendedStatus: "N/A", + }, + ]); + break; + + default: + console.warn( + "Skipping invalid operation received from '/hmi_start_stop':", + msg, + ); + } + } + /** * Subscribes to the specified topic. * @@ -375,7 +482,9 @@ function ROSProvider({ ...props }) { } return ( - + { }); beforeEach(() => { - mockServer = new WS("ws://127.0.0.1:9090"); + mockServer = new WS(config.rosIp); }); it("connects to the WebSocket on mount and calls '/node_info_srv'", async () => { @@ -204,7 +205,6 @@ describe("ROSProvider", () => { const discoveredNodesEl = within(container).getByTestId("discovered-nodes"); const discoveredNodes = JSON.parse(discoveredNodesEl.textContent || ""); - console.log(discoveredNodes); expect("/hmi_com" in discoveredNodes).toBeTruthy(); expect("/test_node" in discoveredNodes).toBeTruthy(); diff --git a/app/src/css/App.css b/app/src/css/App.css deleted file mode 100644 index 4fd8687..0000000 --- a/app/src/css/App.css +++ /dev/null @@ -1,7 +0,0 @@ -#root { - margin: 0 auto; - padding: 0; - text-align: center; - background-color: #ddd; - min-height: 100dvh; -} diff --git a/app/src/css/index.css b/app/src/css/index.css index d30601a..aee8776 100644 --- a/app/src/css/index.css +++ b/app/src/css/index.css @@ -1,57 +1,12 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; +@font-face { + font-family: "Cascadia Code"; + src: url("/fonts/CascadiaCode/CascadiaCode.woff2") format("woff2-variations"); + font-weight: 200 700; + font-style: normal; + font-display: swap; /* This is good practice for performance */ } body { margin: 0; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; } diff --git a/app/src/main.tsx b/app/src/main.tsx index 0e701ac..81e0703 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -1,16 +1,18 @@ +import "@fontsource/inter"; +import { CssBaseline, ThemeProvider } from "@mui/material"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import "./css/index.css"; import App from "./components/App"; -import "@fontsource/inter"; -import { CssBaseline } from "@mui/material"; import ROSProvider from "./components/Providers/ROSProvider"; +import { theme } from "./theme"; createRoot(document.getElementById("root")!).render( - - - - + + + + + + , ); diff --git a/app/src/theme.ts b/app/src/theme.ts new file mode 100644 index 0000000..2612056 --- /dev/null +++ b/app/src/theme.ts @@ -0,0 +1,110 @@ +import { createTheme } from "@mui/material/styles"; + +export const theme = createTheme({ + palette: { + mode: "dark", + primary: { + main: "#b89659", + light: "#d4b17a", + dark: "#9a7a3a", + contrastText: "#000", + }, + secondary: { + main: "#0d553f", + light: "#1a7a5f", + dark: "#0a3f2f", + contrastText: "#fff", + }, + background: { + default: "#121212", + paper: "#1e1e1e", + }, + text: { + primary: "#ffffff", + secondary: "#b3b3b3", + }, + divider: "#333333", + action: { + active: "#ffffff", + hover: "rgba(255, 255, 255, 0.08)", + selected: "rgba(255, 255, 255, 0.16)", + disabled: "rgba(255, 255, 255, 0.3)", + disabledBackground: "rgba(255, 255, 255, 0.12)", + }, + }, + typography: { + fontFamily: "Inter, system-ui, Avenir, Helvetica, Arial, sans-serif", + h4: { + fontWeight: 600, + }, + subtitle1: { + fontSize: "0.875rem", + fontWeight: 600, + }, + body2: { + fontSize: "0.75rem", + }, + }, + components: { + MuiCssBaseline: { + styleOverrides: { + body: { + backgroundColor: "#121212", + color: "#ffffff", + }, + ":root": { + "--button-bg": "#2a2a2a", // Custom CSS variable for button background + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundColor: "#1e1e1e", + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + backgroundColor: "#1e1e1e", + }, + }, + }, + // Optimize components for small screens + MuiChip: { + styleOverrides: { + root: { + height: "24px", + fontSize: "0.7rem", + }, + label: { + paddingLeft: "8px", + paddingRight: "8px", + }, + }, + }, + MuiToggleButton: { + styleOverrides: { + root: { + padding: "4px 8px", + fontSize: "0.75rem", + }, + sizeSmall: { + padding: "2px 4px", + fontSize: "0.7rem", + }, + }, + }, + }, + // Add responsive breakpoints for small screens + breakpoints: { + values: { + xs: 0, + sm: 600, + md: 960, + lg: 1280, + xl: 1920, + }, + }, +}); diff --git a/app/src/types/config.ts b/app/src/types/config.ts new file mode 100644 index 0000000..aa56932 --- /dev/null +++ b/app/src/types/config.ts @@ -0,0 +1,14 @@ +import { JSONSchemaType } from "ajv"; + +export interface AppConfig { + rosIp: string; +} + +export const schema: JSONSchemaType = { + type: "object", + properties: { + rosIp: { type: "string" }, + }, + required: ["rosIp"], + additionalProperties: false, +}; diff --git a/app/src/types/dashboard.ts b/app/src/types/dashboard.ts new file mode 100644 index 0000000..e268c9f --- /dev/null +++ b/app/src/types/dashboard.ts @@ -0,0 +1,5 @@ +export type Dashboard = {}; + +export type RunningTimeClock = { + timestamp: Date; +}; diff --git a/app/src/types/navbar.ts b/app/src/types/navbar.ts index e6def36..9322c31 100644 --- a/app/src/types/navbar.ts +++ b/app/src/types/navbar.ts @@ -1,4 +1,5 @@ import { Dispatch, SetStateAction } from "react"; +import { GlobalStatus } from "./rosProvider"; import { Status } from "./status"; export type StatusSummary = { @@ -7,14 +8,14 @@ export type StatusSummary = { export type StartButton = { status: Status; - setStatus: Dispatch>; + setStatus: Dispatch>; }; export type NavBar = { status: Status; - setStatus: Dispatch>; + setStatus: Dispatch>; }; export type EStop = { - setStatus: Dispatch>; + style: "large" | "small"; }; diff --git a/app/src/types/rosProvider.ts b/app/src/types/rosProvider.ts index 42adeb8..b47596c 100644 --- a/app/src/types/rosProvider.ts +++ b/app/src/types/rosProvider.ts @@ -23,6 +23,7 @@ export type DiscoveredNodeInfo = { service_clients: DiscoveredTopic[]; service_servers: DiscoveredTopic[]; subscribers: DiscoveredTopic[]; + status: Status; }; export type DiscoveredTopic = { @@ -64,8 +65,14 @@ export type RosNodeConnection = { }; export type GlobalStatusContextType = { - globalStatus: Status; - setGlobalStatus: Dispatch>; + globalStatusHistory: GlobalStatus[]; + setGlobalStatusHistory: Dispatch>; +}; + +export type GlobalStatus = { + timestamp: Date; + status: Status; + extendedStatus?: string; }; export type RosCommunicationContext = { diff --git a/app/src/util/config.ts b/app/src/util/config.ts new file mode 100644 index 0000000..4dcd946 --- /dev/null +++ b/app/src/util/config.ts @@ -0,0 +1,20 @@ +import Ajv from "ajv"; +import uncheckedConfig from "../../config/main.yaml"; +import { AppConfig, schema } from "../types/config"; + +const ajv = new Ajv(); +const validate = ajv.compile(schema); + +let config: AppConfig; + +if (validate(uncheckedConfig)) { + config = uncheckedConfig; + console.log("Configuration validated successfully!"); +} else { + console.error("Configuration validation failed:"); + console.error(validate.errors); + // Handle the error appropriately, e.g., throw an error, use default config, etc. + throw new Error("Invalid configuration"); +} + +export default config; diff --git a/app/src/util/util.ts b/app/src/util/util.ts index df44acc..fedde48 100644 --- a/app/src/util/util.ts +++ b/app/src/util/util.ts @@ -1,3 +1,5 @@ +import { differenceInSeconds } from "date-fns"; + type NestedObject = { [key: string]: any; // Allow nested objects or arrays or literally anything }; @@ -50,3 +52,49 @@ export function deepCombineObjects( return result; } + +/** + * Formats the difference between 2 dates in `mm:ss` (or `HH:mm:ss` if it is over an hour difference). + * + * @param earlier The earlier date + * @param later The later date + * @returns A string in the form `mm:ss` or `HH:mm:ss` representing the difference in time between the two timestamps. + */ +export function formatTimeDifference(earlier: Date, later: Date) { + const diffInSeconds = differenceInSeconds(later, earlier); + + const hours = Math.floor(diffInSeconds / 3600); + const minutes = Math.floor((diffInSeconds % 3600) / 60); + const seconds = diffInSeconds % 60; + + if (hours > 0) { + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } else { + return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } +} + +/** + * Formats the difference between 2 dates in an abbreviated form. + * + * @param earlier The earlier date + * @param later The later date + * @returns A string in the form `Hh Mm Ss` (no pad, only show min when min > 0) + */ +export function formatTimeDifferenceAbbreviated(earlier: Date, later: Date) { + const diffInSeconds = differenceInSeconds(later, earlier); + + const hours = Math.floor(diffInSeconds / 3600); + const minutes = Math.floor((diffInSeconds % 3600) / 60); + const seconds = diffInSeconds % 60; + + let result = ""; + if (hours > 0) { + result += `${hours}h `; + } + if (minutes > 0) { + result += `${minutes}m `; + } + result += `${seconds}s`; + return result.trim(); +} diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts index 11f02fe..51dad20 100644 --- a/app/src/vite-env.d.ts +++ b/app/src/vite-env.d.ts @@ -1 +1,6 @@ /// + +declare module "*.yaml" { + const data: any; // You can use 'any' for simplicity, but typing it is better + export default data; +} diff --git a/app/vite.config.ts b/app/vite.config.ts index 8b93076..61fb67f 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -1,12 +1,13 @@ /// /// -import { defineConfig } from "vite"; +import yaml from "@modyfi/vite-plugin-yaml"; import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), yaml()], test: { globals: true, environment: "jsdom", diff --git a/app/yarn.lock b/app/yarn.lock index c8579f8..7331509 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -601,6 +601,15 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@modyfi/vite-plugin-yaml@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@modyfi/vite-plugin-yaml/-/vite-plugin-yaml-1.1.1.tgz#f3d85a76aebbbe3c143e8f783a59ce1dcf391670" + integrity sha512-rEbfFNlMGLKpAYs2RsfLAhxCHFa6M4QKHHk0A4EYcCJAUwFtFO6qiEdLjUGUTtnRUxAC7GxxCa+ZbeUILSDvqQ== + dependencies: + "@rollup/pluginutils" "5.1.0" + js-yaml "4.1.0" + tosource "2.0.0-alpha.3" + "@mui/core-downloads-tracker@^6.4.7": version "6.4.7" resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.7.tgz#df62091560024a6412b0f35dcd584f9dba70dced" @@ -714,6 +723,15 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== +"@rollup/pluginutils@5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.0.tgz#7e53eddc8c7f483a4ad0b94afb1f7f5fd3c771e0" + integrity sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^2.3.1" + "@rollup/rollup-android-arm-eabi@4.34.9": version "4.34.9" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz#661a45a4709c70e59e596ec78daa9cb8b8d27604" @@ -896,6 +914,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== +"@types/js-yaml@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" + integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== + "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -1112,6 +1135,16 @@ agent-base@^7.1.0, agent-base@^7.1.2: resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.3.tgz#29435eb821bc4194633a5b89e5bc4703bafc25a1" integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== +ajv@8: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -1632,6 +1665,11 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + estree-walker@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" @@ -1675,6 +1713,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-uri@^3.0.1: + version "3.0.6" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.6.tgz#88f130b77cfaea2378d56bf970dea21257a68748" + integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw== + fastq@^1.6.0: version "1.19.1" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" @@ -2031,7 +2074,7 @@ jest-websocket-mock@^2.5.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@^4.1.0: +js-yaml@4.1.0, js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== @@ -2085,6 +2128,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" @@ -2492,6 +2540,11 @@ regenerator-runtime@^0.14.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -2755,6 +2808,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +tosource@2.0.0-alpha.3: + version "2.0.0-alpha.3" + resolved "https://registry.yarnpkg.com/tosource/-/tosource-2.0.0-alpha.3.tgz#ef385dac9092e009bf25c018838ddaae436daeb6" + integrity sha512-KAB2lrSS48y91MzFPFuDg4hLbvDiyTjOVgaK7Erw+5AmZXNq4sFRVn8r6yxSLuNs15PaokrDRpS61ERY9uZOug== + tough-cookie@^5.0.0: version "5.1.2" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-5.1.2.tgz#66d774b4a1d9e12dc75089725af3ac75ec31bed7"