diff --git a/bun.lock b/bun.lock index 8e3e629..594bafb 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "maxim", "dependencies": { + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.13", "class-variance-authority": "^0.7.1", @@ -12,7 +13,6 @@ "next-themes": "^0.4.6", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.3", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", @@ -131,10 +131,28 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.32", "", {}, "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.50.2", "", { "os": "android", "cpu": "arm" }, "sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A=="], @@ -299,8 +317,6 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], @@ -477,10 +493,6 @@ "react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="], - "react-router": ["react-router@7.9.3", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg=="], - - "react-router-dom": ["react-router-dom@7.9.3", "", { "dependencies": { "react-router": "7.9.3" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg=="], - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -493,8 +505,6 @@ "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], diff --git a/package.json b/package.json index fe0dfc8..200c55b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.13", "class-variance-authority": "^0.7.1", @@ -18,7 +19,6 @@ "next-themes": "^0.4.6", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.9.3", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13" diff --git a/src/App.css b/src/App.css index 8fd7e20..0cc2235 100644 --- a/src/App.css +++ b/src/App.css @@ -1,72 +1,6 @@ #root { margin: 0 auto; text-align: center; -} - -/* Flash animation with more opaque primary color for better text visibility */ -.flash { - animation: flash 1.5s ease-out; -} - -@keyframes flash { - 0% { - background-color: color-mix(in srgb, var(--primary) 30%, transparent 70%); - transform: scale(1.02); - box-shadow: 0 0 0 0 var(--primary); - } - 50% { - background-color: color-mix(in srgb, var(--primary) 50%, transparent 50%); - transform: scale(1.01); - box-shadow: 0 0 8px var(--primary); - } - 100% { - background-color: var(--card); - transform: scale(1); - box-shadow: 0 0 0 0 transparent; - } -} - -/* Value increase animation (green) */ -@keyframes value-increase { - 0% { - color: rgb(34, 197, 94); - transform: scale(1.05); - } - 50% { - color: rgb(22, 163, 74); - transform: scale(1.02); - } - 100% { - color: rgb(17, 24, 39); - transform: scale(1); - } -} - -/* Value decrease animation (red) */ -@keyframes value-decrease { - 0% { - color: rgb(239, 68, 68); - transform: scale(1.05); - } - 50% { - color: rgb(220, 38, 38); - transform: scale(1.02); - } - 100% { - color: rgb(17, 24, 39); - transform: scale(1); - } -} - -/* Apply animations to elements */ -[class*="flash-"] { - animation: flash 1.5s ease-out; -} - -.animate-value-increase { - animation: value-increase 1.5s ease-out; -} - -.animate-value-decrease { - animation: value-decrease 1.5s ease-out; + padding: 0; + width: 100%; } diff --git a/src/App.tsx b/src/App.tsx index 380a5f6..35dd8cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,9 @@ import "./App.css"; import { Toaster } from "./components/ui/sonner"; -import WebhookViewer from "./pages/WebhookViewer"; -import { Link, Route, Routes } from "react-router-dom"; -import WebhookSender from "./pages/WebhookSender"; +import WebhookViewer from "./components/common/WebhookViewer"; +import WebhookSender from "./components/common/WebhookSender"; +import { useWebhookConnection } from "./hooks/useWebSocketConnection"; +import { useEffect, useState } from "react"; const toastConfig = { position: "bottom-center" as const, @@ -12,25 +13,37 @@ const toastConfig = { }; function App() { + const { connectionStatus, lastMetrics, disconnect, connect, sendCommand } = + useWebhookConnection(); + + const [currentTime, setCurrentTime] = useState(new Date()); + + // Update current time every second for live counter + useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + + return () => clearInterval(interval); + }, []); + return ( <> -
-
-

+
+
+

Training Month - Hyperloop H11

- -
-
- - } /> - } /> - +
+ +
diff --git a/src/components/common/CommandButton.tsx b/src/components/common/CommandButton.tsx new file mode 100644 index 0000000..1c65281 --- /dev/null +++ b/src/components/common/CommandButton.tsx @@ -0,0 +1,208 @@ +import { useState, useCallback, memo } from "react"; +import { Button } from "../ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ChevronDown, Settings2 } from "lucide-react"; +import type { Action } from "@/types/Action"; +import type { CommandParam } from "@/constants/commands"; +import type Command from "@/types/Command"; + +interface CommandButtonProps { + label: string; + action: Action; + params?: CommandParam[]; + fixedParams?: Record; + variant?: + | "default" + | "destructive" + | "outline" + | "secondary" + | "ghost" + | "link" + | null; + classname?: string; + onSendCommand: (command: Command) => void; +} + +const CommandButton = memo( + ({ + label, + action, + params, + fixedParams, + variant, + classname, + onSendCommand, + }: CommandButtonProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const [paramValues, setParamValues] = useState< + Record + >(() => { + // Initialize with default values + const initial: Record = {}; + params?.forEach((param) => { + if (param.defaultValue !== undefined) { + initial[param.name] = param.defaultValue; + } + }); + return initial; + }); + + const hasParams = params && params.length > 0; + + const handleParamChange = useCallback( + (paramName: string, value: string | number) => { + setParamValues((prev) => ({ + ...prev, + [paramName]: value, + })); + }, + [] + ); + + const buildCommand = useCallback((): Command => { + // Start with an empty object to accumulate all params + let allParams: Record = {}; + + // Add fixed params first (if any) + if (fixedParams) { + allParams = { ...fixedParams }; + } + + // Add configurable params from user input (if any) + if (params && params.length > 0) { + params.forEach((param) => { + const value = + paramValues[param.name] ?? + param.defaultValue ?? + (param.type === "number" ? 0 : ""); + allParams[param.name] = + param.type === "number" ? Number(value) : String(value); + }); + } + + // Determine how to return params based on what we have + const totalParamCount = Object.keys(allParams).length; + + if (totalParamCount === 0) { + // No params at all + return { action, params: null }; + } else if (totalParamCount === 1) { + // Single param - return just the value (not an object) + const singleValue = allParams[Object.keys(allParams)[0]]; + return { action, params: singleValue }; + } else { + // Multiple params - return as object + return { action, params: allParams }; + } + }, [action, params, fixedParams, paramValues]); + + const handleClick = useCallback(() => { + onSendCommand(buildCommand()); + }, [buildCommand, onSendCommand]); + + // Simple button for commands without params + if (!hasParams) { + return ( + + ); + } + + // Expandable command with params using Collapsible + return ( + +
+ {/* Main button row with integrated expand trigger */} +
+ + + + + +
+ + {/* Parameters section */} + +
+
+ + Parameters +
+ + {params.map((param) => ( +
+ + + handleParamChange( + param.name, + param.type === "number" + ? Number(e.target.value) + : e.target.value + ) + } + className="w-full px-3 py-2 text-sm border border-input rounded-md bg-background/80 backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent transition-all shadow-sm hover:shadow-md" + placeholder={`Enter ${param.label.toLowerCase()}`} + /> +
+ ))} +
+
+
+
+ ); + } +); + +export default CommandButton; diff --git a/src/components/common/MetricBox.tsx b/src/components/common/MetricBox.tsx index 27a0694..776a62e 100644 --- a/src/components/common/MetricBox.tsx +++ b/src/components/common/MetricBox.tsx @@ -3,7 +3,7 @@ import { cn, formatLastUpdatedLive, formatMetricValue, - formatSnakeCaseToTitle, + formatPascalCaseToTitle, } from "@/lib/utils"; import { Card, @@ -22,7 +22,7 @@ import React from "react"; interface MetricBoxProps extends React.ComponentProps<"div"> { metricLabel: string; - metricData: { value: number; lastUpdated: Date }; + metricData: { value: number | string; lastUpdated: Date }; currentTime: Date; } @@ -35,11 +35,13 @@ const MetricBox = ({ const cardRef = useRef(null); const valueRef = useRef(null); - const [previousValue, setPreviousValue] = useState(null); + const [previousValue, setPreviousValue] = useState( + null + ); const runFlashAnimation = useCallback(() => { cardRef.current?.animate(valueChangedAnimation, { - duration: 1000, + duration: 500, easing: "ease-out", }); }, []); @@ -49,6 +51,13 @@ const MetricBox = ({ return; } + if ( + typeof metricData.value !== "number" || + typeof previousValue !== "number" + ) { + return; + } + if (metricData.value > previousValue) { valueRef.current?.animate(valueIncreaseAnimation, { duration: 1200, @@ -95,7 +104,7 @@ const MetricBox = ({ id={`metric-title-${metricLabel}`} className="text-sm text-muted-foreground uppercase tracking-wide" > - {formatSnakeCaseToTitle(metricLabel)} + {formatPascalCaseToTitle(metricLabel)} diff --git a/src/components/common/WebhookSender.tsx b/src/components/common/WebhookSender.tsx new file mode 100644 index 0000000..af212a2 --- /dev/null +++ b/src/components/common/WebhookSender.tsx @@ -0,0 +1,169 @@ +import { useCallback, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ToastNotifications } from "@/lib/notifications"; +import { COMMAND_GROUPS } from "@/constants/commands"; +import useDocumentTitle from "@/hooks/useDocumentTitle"; +import type Command from "@/types/Command"; +import CommandButton from "@/components/common/CommandButton"; +import ConnectButton from "@/components/common/ConnectButton"; +import { Badge } from "@/components/ui/badge"; +import { getStatusColor, getStatusVariant } from "@/lib/statusUtils"; +import type { ConnectionStatus } from "@/types/ConnectionsStatus"; +import { ChevronDown } from "lucide-react"; + +interface WebhookSenderProps { + sendCommand: (command: Command) => void; + connectionStatus: ConnectionStatus; + disconnect: () => void; + connect: () => void; +} + +const WebhookSender = ({ + sendCommand, + connectionStatus, + disconnect, + connect, +}: WebhookSenderProps) => { + useDocumentTitle("Webhook Sender - Hyperloop H11"); + const [openSections, setOpenSections] = useState>( + Object.fromEntries( + COMMAND_GROUPS.map((group) => [group.title, group.defaultOpen]) + ) + ); + + const handleSendCommand = useCallback( + async (command: Command) => { + try { + sendCommand(command); + ToastNotifications.showCommandResult(command); + console.log("Command sent successfully", command); + } catch (error: unknown) { + ToastNotifications.showTextError( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error instanceof Error ? error.message : (error as any).toString() + ); + console.error(error instanceof Error ? error.message : error); + } + }, + [sendCommand] + ); + + const toggleSection = (title: string) => { + setOpenSections((prev) => ({ + ...prev, + [title]: !prev[title], + })); + }; + + return ( + + +
+
+ + Command Sender + + + Send predefined actions + +
+
+ + +
+ + + + {COMMAND_GROUPS.map((group) => ( + toggleSection(group.title)} + > +
+ +
+

{group.title}

+

+ {group.description} +

+
+ +
+ + + {group.commands.map( + ({ + action, + label, + params, + fixedParams, + variant, + classname, + }) => ( + + ) + )} + +
+
+ ))} +
+ + ); +}; + +export default WebhookSender; diff --git a/src/components/common/WebhookViewer.tsx b/src/components/common/WebhookViewer.tsx new file mode 100644 index 0000000..d3e4cf8 --- /dev/null +++ b/src/components/common/WebhookViewer.tsx @@ -0,0 +1,70 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import MetricBox from "./MetricBox"; +import { WEBHOOK_URL } from "@/constants/urls"; +import useDocumentTitle from "@/hooks/useDocumentTitle"; +import type { LastMetrics } from "@/types/LastMetrics"; + +interface WebhookViewerProps { + lastMetrics: LastMetrics; + currentTime: Date; +} + +const WebhookViewer = ({ lastMetrics, currentTime }: WebhookViewerProps) => { + useDocumentTitle("Webhook Viewer - Hyperloop H11"); + + return ( + + +
+
+ Webhook Metrics + + Real-time monitoring of incoming webhook data + +
+
+ +
+ Endpoint:{" "} + + {WEBHOOK_URL} + +
+
+ + +
+ {Object.entries(lastMetrics).map(([key, metricData]) => ( + + ))} +
+ + {Object.keys(lastMetrics).length === 0 && ( +
+

No metrics received yet

+

+ Connect to start receiving webhook data +

+
+ )} +
+
+ ); +}; + +export default WebhookViewer; diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..77f86be --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,31 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/constants/actions.ts b/src/constants/actions.ts deleted file mode 100644 index 78288d6..0000000 --- a/src/constants/actions.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Action } from "@/types/Action"; -import { buttonVariants } from "@/components/ui/button"; -import type { VariantProps } from "class-variance-authority"; - -type ButtonVariant = VariantProps["variant"]; - -export type ActionItem = { - label: string; - action: Action; - classname?: string; - variant?: ButtonVariant; -}; - -export const ACTIONS: readonly ActionItem[] = [ - { - label: "Launch", - action: "launch", - classname: - "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20", - }, - { - label: "Detain", - action: "detain", - classname: - "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20", - }, - { - label: "Reset", - action: "reset", - classname: - "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20", - }, - { label: "Error", action: "error", variant: "destructive" }, -]; diff --git a/src/constants/animations.ts b/src/constants/animations.ts index bb63661..52c9a01 100644 --- a/src/constants/animations.ts +++ b/src/constants/animations.ts @@ -1,15 +1,15 @@ export const valueChangedAnimation = [ { - transform: "scale(1.02)", - boxShadow: "0 0 0 0 var(--primary)", + transform: "scale(1.01)", + boxShadow: "0 0 3px 1px var(--primary)", }, { transform: "scale(1.01)", - boxShadow: "0 0 8px var(--primary)", + boxShadow: "0 0 3px 1px var(--primary)", }, { - transform: "scale(1)", - boxShadow: "0 0 0 0 transparent", + transform: "scale(1.01)", + boxShadow: "0px 0 3px 0 transparent", }, ]; diff --git a/src/constants/commands.ts b/src/constants/commands.ts new file mode 100644 index 0000000..ac67069 --- /dev/null +++ b/src/constants/commands.ts @@ -0,0 +1,93 @@ +import type { Action } from "@/types/Action"; +import { buttonVariants } from "@/components/ui/button"; +import type { VariantProps } from "class-variance-authority"; + +type ButtonVariant = VariantProps["variant"]; + +export type CommandParam = { + name: string; + label: string; + type: "number" | "string"; + defaultValue?: string | number; +}; + +export type CommandItem = { + label: string; + action: Action; + params?: CommandParam[]; + fixedParams?: Record; + classname?: string; + variant?: ButtonVariant; +}; + +export const COMMANDS: readonly CommandItem[] = [ + { + label: "Start", + action: "start", + classname: + "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20", + }, + { + label: "Stop", + action: "stop", + classname: + "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20", + }, + { + label: "Accelerate", + action: "accelerate", + params: [ + { + name: "acceleration", + label: "Acceleration", + type: "number", + defaultValue: 10, + }, + ], + classname: + "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20", + }, + { + label: "Mode Eco", + action: "mode", + fixedParams: { mode: "eco" }, + classname: + "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20", + }, + { + label: "Mode Normal", + action: "mode", + fixedParams: { mode: "normal" }, + classname: + "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20", + }, + { + label: "Mode Sport", + action: "mode", + fixedParams: { mode: "sport" }, + classname: + "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20", + }, +]; + +// Group commands by category +export const COMMAND_GROUPS = [ + { + title: "Basic Controls", + description: "Start and stop operations", + commands: COMMANDS.filter((cmd) => ["start", "stop"].includes(cmd.action)), + defaultOpen: true, + }, + { + title: "Motion Controls", + description: "Control acceleration and movement", + commands: COMMANDS.filter((cmd) => cmd.action === "accelerate"), + defaultOpen: true, + }, + { + title: "Mode Selection", + description: "Switch between operating modes", + commands: COMMANDS.filter((cmd) => cmd.action === "mode"), + defaultOpen: false, + }, +] as const; diff --git a/src/constants/unitMap.ts b/src/constants/unitMap.ts new file mode 100644 index 0000000..557dbc6 --- /dev/null +++ b/src/constants/unitMap.ts @@ -0,0 +1,13 @@ +const unitMap: Record = { + AverageTemperature: " \u00B0C", + MinimumTemperature: " \u00B0C", + MaximumTemperature: " \u00B0C", + AveragePressure: " hPa", + MinimumPressure: " hPa", + MaximumPressure: " hPa", + AverageSpeed: " km/h", + MinimumSpeed: " km/h", + MaximumSpeed: " km/h", +}; + +export default unitMap; diff --git a/src/constants/urls.ts b/src/constants/urls.ts index be10736..bf18bd2 100644 --- a/src/constants/urls.ts +++ b/src/constants/urls.ts @@ -1,2 +1,2 @@ -export const WEBHOOK_URL = "ws://localhost:3000"; +export const WEBHOOK_URL = "ws://localhost:3000/api/stream"; export const API_URL = "http://localhost:3000"; diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 12e0c16..75b05e0 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -52,12 +52,7 @@ export const useWebSocket = ( useState("disconnected"); const [messages, setMessages] = useState([]); - const [lastMetrics, setLastMetrics] = useState({ - humidity: { value: 0, lastUpdated: new Date() }, - temperature: { value: 0, lastUpdated: new Date() }, - signal_strength: { value: 0, lastUpdated: new Date() }, - battery_level: { value: 0, lastUpdated: new Date() }, - }); + const [lastMetrics, setLastMetrics] = useState({}); const wsRef = useRef(null); @@ -110,13 +105,17 @@ export const useWebSocket = ( // Dynamically update any metric that exists in the received data Object.keys(receivedData).forEach((key) => { - if ( - key !== "timestamp" && - receivedData[key as keyof MetricMessage] !== undefined - ) { - const newValue = parseFloat( - receivedData[key as keyof MetricMessage] as string - ); + let newValue: number | string | undefined = + receivedData[key as keyof MetricMessage]; + if (key !== "timestamp" && newValue !== undefined) { + if (typeof newValue === "number") { + newValue = parseFloat(newValue) || 0; + } + + if (typeof newValue === "string") { + newValue = newValue || ""; + } + if (updated[key as keyof LastMetrics]?.value !== newValue) { updated[key as keyof LastMetrics] = { value: newValue, @@ -179,6 +178,7 @@ export const useWebSocket = ( wsRef.current.send(message); } else { console.warn("WebSocket is not connected. Cannot send message."); + throw new Error("WebSocket is not connected. Cannot send message."); } }, []); diff --git a/src/hooks/useWebSocketConnection.ts b/src/hooks/useWebSocketConnection.ts index 6dfc2b5..7ac5d4f 100644 --- a/src/hooks/useWebSocketConnection.ts +++ b/src/hooks/useWebSocketConnection.ts @@ -4,11 +4,12 @@ import { ToastNotifications } from "@/lib/notifications"; import { CONSOLE_MESSAGES } from "@/constants/messages"; import { WEBHOOK_URL } from "@/constants/urls"; import type MetricMessage from "@/types/MetricMessage"; +import type Command from "@/types/Command"; export const useWebhookConnection = () => { // Function executed when a new message is received const handleMessage = useCallback((data: MetricMessage) => { - ToastNotifications.showNewMessage(); + // ToastNotifications.showNewMessage(); console.log(CONSOLE_MESSAGES.NEW_MESSAGE, data); }, []); @@ -71,7 +72,7 @@ export const useWebhookConnection = () => { const webSocketHook = useWebSocket({ url: WEBHOOK_URL, - autoConnect: true, + autoConnect: false, ...webSocketCallbacks, }); @@ -95,10 +96,18 @@ export const useWebhookConnection = () => { handleDisconnectAsync(webSocketHook.disconnectAsync); }, [webSocketHook, handleDisconnectAsync]); + const sendCommand = useCallback( + (command: Command) => { + webSocketHook.sendMessage(JSON.stringify(command)); + }, + [webSocketHook] + ); + return { ...webSocketHook, handleClearMessages, disconnect, connect, + sendCommand, }; }; diff --git a/src/index.css b/src/index.css index 5d61dcf..cf9dcad 100644 --- a/src/index.css +++ b/src/index.css @@ -1,6 +1,10 @@ @import "tailwindcss"; @import "tw-animate-css"; +@theme { + --breakpoint-3xl: 120rem; +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -113,10 +117,12 @@ body { margin: 0; + padding: 0; display: flex; place-items: center; min-width: 320px; min-height: 100vh; + width: 100%; } @layer base { diff --git a/src/lib/api.ts b/src/lib/api.ts deleted file mode 100644 index 7960302..0000000 --- a/src/lib/api.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { API_URL } from "@/constants/urls"; - -export type Command = string; - -export type CommandResponse = { - status: string; - command: string; -}; - -export async function sendCommand( - command: Command, - opts?: { signal?: AbortSignal } -): Promise { - const res = await fetch(`${API_URL}/api/command`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ command }), - signal: opts?.signal, - }); - - // Attempt to parse JSON even on non-2xx for better messages - const text = await res.text(); - const data = text ? safeJson(text) : {}; - - if (!res.ok) { - const msg = - (data && (data.message || data.status)) || - `Request failed with ${res.status}`; - throw new Error(msg); - } - - return data as CommandResponse; -} - -function safeJson(text: string) { - try { - return JSON.parse(text); - } catch { - return {}; - } -} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index ba6c509..36b4d93 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -1,7 +1,7 @@ import { toast } from "sonner"; import { getFormattedDate } from "@/lib/utils"; import { TOAST_MESSAGES, TOAST_DESCRIPTIONS } from "@/constants/messages"; -import type { CommandResponse } from "./api"; +import type Command from "@/types/Command"; function showNewMessage() { toast.info(TOAST_MESSAGES.NEW_MESSAGE, { @@ -53,15 +53,11 @@ function showTextSuccess(text: string) { }); } -function showCommandResult(promise: Promise, command: string) { - toast.promise(promise, { - loading: TOAST_MESSAGES.SENDING_COMMAND, - success: (data) => { - return `${TOAST_MESSAGES.SUCCESS} ${command}: ${data.status}`; - }, - error: (error) => { - return `${TOAST_MESSAGES.ERROR} ${command}: ${error.message}`; - }, +function showCommandResult(command: Command) { + toast.success(TOAST_MESSAGES.SUCCESS, { + description: `${TOAST_MESSAGES.SUCCESS} ${command.action}${ + command.params ? ` ${JSON.stringify(command.params)}` : "" + }`, }); } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 856caf0..47411c1 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,3 +1,4 @@ +import unitMap from "@/constants/unitMap"; import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; @@ -33,6 +34,16 @@ export const formatSnakeCaseToTitle = (str: string): string => { .join(" "); }; +// Converts PascalCase to Title Case +// Example: "BatteryLevel" -> "Battery Level" +export const formatPascalCaseToTitle = (str: string) => { + return str + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") // Handle acronyms: "XMLParser" → "XML Parser" + .replace(/([a-z\d])([A-Z])/g, "$1 $2") // Regular PascalCase: "myVar" → "my Var" + .replace(/^./, (char) => char.toUpperCase()) // Ensure first char is uppercase + .trim(); +}; + // Use currentTime for live updates export const formatLastUpdatedLive = ( date: Date, @@ -59,14 +70,30 @@ export const formatLastUpdatedLive = ( } }; +export const isISODateString = (str: string): boolean => { + // Check if string matches ISO 8601 format + const isoRegex = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; + + if (!isoRegex.test(str)) { + return false; + } + + const date = new Date(str); + return !isNaN(date.getTime()); +}; + // Utility function to format metric values with units -export const formatMetricValue = (key: string, value: number): string => { - const unitMap: Record = { - battery_level: "%", - temperature: " \u00B0C", - humidity: "%", - signal_strength: " dBm", - }; +export const formatMetricValue = ( + key: string, + value: number | string +): string => { + if (typeof value !== "number") { + if (isISODateString(value)) { + return new Date(value).toLocaleString(); + } + return value.toString(); + } const unit = unitMap[key] || ""; return `${value.toFixed(2)}${unit}`; diff --git a/src/main.tsx b/src/main.tsx index d52d514..10ed13e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,12 +2,9 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App.tsx"; -import { BrowserRouter } from "react-router-dom"; createRoot(document.getElementById("root")!).render( - - - + ); diff --git a/src/pages/WebhookSender.tsx b/src/pages/WebhookSender.tsx deleted file mode 100644 index f577fce..0000000 --- a/src/pages/WebhookSender.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useState, useCallback } from "react"; -import { Button } from "../components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "../components/ui/card"; -import { sendCommand } from "@/lib/api"; -import { ToastNotifications } from "@/lib/notifications"; -import { API_URL } from "@/constants/urls"; -import type { Action } from "@/types/Action"; -import { ACTIONS } from "@/constants/actions"; -import { Spinner } from "../components/ui/spinner"; -import useDocumentTitle from "@/hooks/useDocumentTitle"; - -const WebhookSender = () => { - useDocumentTitle("Webhook Sender - Hyperloop H11"); - - const [pendingActions, setPendingActions] = useState>(new Set()); - - // Memoized helper to add action to pending set - const addPending = useCallback((action: Action) => { - setPendingActions((prev) => new Set(prev).add(action)); - }, []); - - // Memoized helper to remove action from pending set - const removePending = useCallback((action: Action) => { - setPendingActions((prev) => { - const next = new Set(prev); - next.delete(action); - return next; - }); - }, []); - - const handleSendCommand = useCallback( - async (action: Action) => { - // Skip if already pending - if (pendingActions.has(action)) return; - - addPending(action); - - try { - const req = sendCommand(action); - ToastNotifications.showCommandResult(req, action); - - const res = await req; - console.log("Command sent successfully", res); - } catch (error: unknown) { - console.error(error instanceof Error ? error.message : error); - } finally { - removePending(action); - } - }, - [pendingActions, addPending, removePending] - ); - - return ( - - - - Command Sender - - - Send predefined actions to your backend. - -
- Endpoint:{" "} - - {API_URL}/api/command - -
-
- - -
- {ACTIONS.map(({ label, action, variant, classname }) => { - const isLoading = pendingActions.has(action); - return ( - - ); - })} -
-
-
- ); -}; - -export default WebhookSender; diff --git a/src/pages/WebhookViewer.tsx b/src/pages/WebhookViewer.tsx deleted file mode 100644 index 1c39855..0000000 --- a/src/pages/WebhookViewer.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useEffect, useState } from "react"; -import { Badge } from "../components/ui/badge"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "../components/ui/card"; -import ConnectButton from "../components/common/ConnectButton"; -import MetricBox from "../components/common/MetricBox"; -import { getStatusVariant, getStatusColor } from "@/lib/statusUtils"; -import { WEBHOOK_URL } from "@/constants/urls"; -import { useWebhookConnection } from "@/hooks/useWebSocketConnection"; -import useDocumentTitle from "@/hooks/useDocumentTitle"; - -const WebhookViewer = () => { - useDocumentTitle("Webhook Viewer - Hyperloop H11"); - - const { connectionStatus, lastMetrics, disconnect, connect } = - useWebhookConnection(); - - const [currentTime, setCurrentTime] = useState(new Date()); - - // Update current time every second for live counter - useEffect(() => { - const interval = setInterval(() => { - setCurrentTime(new Date()); - }, 1000); - - return () => clearInterval(interval); - }, []); - - return ( - - -
-
- Webhook Metrics - - Real-time monitoring of incoming webhook data - -
- -
- - -
- -
- Endpoint:{" "} - - {WEBHOOK_URL} - -
- - - - {/* Metrics boxes - grid ensures same size */} -
- {Object.entries(lastMetrics).map(([key, metricData]) => ( - - ))} -
- - {Object.keys(lastMetrics).length === 0 && ( -
-

No metrics received yet

-

- Connect to start receiving webhook data -

-
- )} -
- - ); -}; - -export default WebhookViewer; diff --git a/src/types/Action.ts b/src/types/Action.ts index 68764ae..c26f171 100644 --- a/src/types/Action.ts +++ b/src/types/Action.ts @@ -1 +1 @@ -export type Action = "launch" | "detain" | "reset" | "error"; +export type Action = "start" | "stop" | "accelerate" | "mode"; diff --git a/src/types/Command.ts b/src/types/Command.ts new file mode 100644 index 0000000..4b0ffb1 --- /dev/null +++ b/src/types/Command.ts @@ -0,0 +1,7 @@ +import type { Action } from "./Action"; +import type { CommandParams } from "./CommandParams"; + +export default interface Command { + action: Action; + params: CommandParams; +} diff --git a/src/types/CommandParams.ts b/src/types/CommandParams.ts new file mode 100644 index 0000000..5332427 --- /dev/null +++ b/src/types/CommandParams.ts @@ -0,0 +1,5 @@ +export type CommandParams = + | number + | string + | Record + | null; diff --git a/src/types/LastMetrics.ts b/src/types/LastMetrics.ts index ebcb1a7..ae2b564 100644 --- a/src/types/LastMetrics.ts +++ b/src/types/LastMetrics.ts @@ -1,6 +1,3 @@ export type LastMetrics = { - humidity: { value: number; lastUpdated: Date }; - temperature: { value: number; lastUpdated: Date }; - signal_strength: { value: number; lastUpdated: Date }; - battery_level: { value: number; lastUpdated: Date }; + [key: string]: { value: number | string; lastUpdated: Date }; }; diff --git a/tailwindcss-oxide.win32-x64-msvc.node b/tailwindcss-oxide.win32-x64-msvc.node deleted file mode 100644 index 11632c0..0000000 Binary files a/tailwindcss-oxide.win32-x64-msvc.node and /dev/null differ