diff --git a/package-lock.json b/package-lock.json index cc9afbc..4d1d38b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bmc-ui", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bmc-ui", - "version": "3.1.0", + "version": "3.2.0", "dependencies": { "@fontsource/inter": "^5.0.18", "@radix-ui/react-checkbox": "^1.0.4", @@ -17,6 +17,7 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", "@tanstack/react-query": "^5.36.0", "@tanstack/react-router": "^1.32.5", @@ -1805,6 +1806,32 @@ } } }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.0.3.tgz", + "integrity": "sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", diff --git a/package.json b/package.json index 5ec68c8..eea4a17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bmc-ui", - "version": "3.1.0", + "version": "3.2.0", "private": true, "type": "module", "scripts": { @@ -21,6 +21,7 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", "@tanstack/react-query": "^5.36.0", "@tanstack/react-router": "^1.32.5", diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 0000000..32768c6 --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,43 @@ +import { Root } from "@radix-ui/react-toggle"; +import { cva, type VariantProps } from "class-variance-authority"; +import { forwardRef } from "react"; + +import { cn } from "@/lib/utils"; + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors hover:bg-neutral-100 hover:text-neutral-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-neutral-100 data-[state=on]:text-neutral-900 dark:ring-offset-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-400 dark:focus-visible:ring-neutral-300 dark:data-[state=on]:bg-neutral-800 dark:data-[state=on]:text-neutral-50", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-neutral-200 bg-transparent hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", + }, + size: { + default: "h-10 px-3", + sm: "h-9 px-2.5", + lg: "h-11 px-5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +const Toggle = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)); + +Toggle.displayName = Root.displayName; + +export { Toggle }; diff --git a/src/lib/api/file.ts b/src/lib/api/file.ts index a0a963d..e807e7b 100644 --- a/src/lib/api/file.ts +++ b/src/lib/api/file.ts @@ -10,6 +10,7 @@ const handleParams = (variables: { sha256?: string; skipCRC?: boolean; node?: number; + batch?: number[]; }) => { const params: Record = { opt: "set", @@ -35,6 +36,10 @@ const handleParams = (variables: { params.node = variables.node; } + if (variables.batch !== undefined) { + params.batch = variables.batch.join(","); + } + return params; }; @@ -114,6 +119,7 @@ export function useNodeUpdateMutation( mutationKey: ["nodeUpdateMutation"], mutationFn: async (variables: { nodeId: number; + batch?: number[]; file?: File; url?: string; sha256?: string; @@ -129,6 +135,7 @@ export function useNodeUpdateMutation( params: handleParams({ type: "flash", node: variables.nodeId, + batch: variables.batch, file: variables.file, url: variables.url, skipCRC: variables.skipCRC, diff --git a/src/lib/api/get.ts b/src/lib/api/get.ts index 68b17b5..0de8198 100644 --- a/src/lib/api/get.ts +++ b/src/lib/api/get.ts @@ -22,7 +22,8 @@ interface PowerTabResponse { } interface AboutTabResponse { - model: string; + board_model: string; + board_revision: string; hostname: string; api: string; version: string; diff --git a/src/routes/_tabLayout/about.lazy.tsx b/src/routes/_tabLayout/about.lazy.tsx index fdeb832..dd5b899 100644 --- a/src/routes/_tabLayout/about.lazy.tsx +++ b/src/routes/_tabLayout/about.lazy.tsx @@ -23,7 +23,9 @@ function About() { return (
- {data.model} + + {data.board_model} (v{data.board_revision}) + {data.hostname} {`v${data.version}`} diff --git a/src/routes/_tabLayout/flash-node.lazy.tsx b/src/routes/_tabLayout/flash-node.lazy.tsx index db93b28..b4923fa 100644 --- a/src/routes/_tabLayout/flash-node.lazy.tsx +++ b/src/routes/_tabLayout/flash-node.lazy.tsx @@ -1,7 +1,8 @@ import { createLazyFileRoute } from "@tanstack/react-router"; import type { AxiosProgressEvent } from "axios"; import { filesize } from "filesize"; -import { useEffect, useRef, useState } from "react"; +import { Cpu } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import ConfirmationModal from "@/components/ConfirmationModal"; import TabView from "@/components/TabView"; @@ -9,34 +10,39 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { Toggle } from "@/components/ui/toggle"; import { useToast } from "@/hooks/use-toast"; import { useNodeUpdateMutation } from "@/lib/api/file"; -import { useFlashStatusQuery } from "@/lib/api/get"; +import { useAboutTabData, useFlashStatusQuery } from "@/lib/api/get"; export const Route = createLazyFileRoute("/_tabLayout/flash-node")({ component: Flash, }); -interface SelectOption { - value: string; - label: string; -} - -const nodeOptions: SelectOption[] = [ - { value: "0", label: "Node 1" }, - { value: "1", label: "Node 2" }, - { value: "2", label: "Node 3" }, - { value: "3", label: "Node 4" }, +const nodeOptions = [ + { value: 0, label: "Node 1" }, + { value: 1, label: "Node 2" }, + { value: 2, label: "Node 3" }, + { value: 3, label: "Node 4" }, ]; +const isSemverGreaterOrEqual = (a: string, b: string) => { + return a.localeCompare(b, undefined, { numeric: true }) >= 0; +}; + function Flash() { + const { data: aboutData } = useAboutTabData(); + const batchFlashingSupport = useMemo( + () => isSemverGreaterOrEqual(aboutData.board_revision, "2.5"), + [aboutData] + ); + + const [selectedNodes, setSelectedNodes] = useState([]); + const nodeWord = useMemo( + () => (selectedNodes.length === 1 ? "node" : "nodes"), + [selectedNodes] + ); + const { toast } = useToast(); const formRef = useRef(null); const [confirmFlashModal, setConfirmFlashModal] = useState(false); @@ -48,7 +54,12 @@ function Flash() { total?: string; pct: number; }>({ transferred: "", total: "", pct: 0 }); - const uploadProgressCallback = (progressEvent: AxiosProgressEvent) => { + + const { + mutate: mutateNodeUpdate, + isIdle, + isPending, + } = useNodeUpdateMutation((progressEvent: AxiosProgressEvent) => { setProgress({ transferred: filesize(progressEvent.loaded ?? 0, { standard: "jedec" }), total: progressEvent.total @@ -58,12 +69,7 @@ function Flash() { ? Math.round((progressEvent.loaded / (progressEvent.total ?? 1)) * 100) : 100, }); - }; - const { - mutate: mutateNodeUpdate, - isIdle, - isPending, - } = useNodeUpdateMutation(uploadProgressCallback); + }); const { data, refetch } = useFlashStatusQuery(isFlashing); useEffect(() => { @@ -77,23 +83,20 @@ function Flash() { setIsFlashing(false); } else if (!isFlashing && data?.Transferring) { setIsFlashing(true); - const msg = ( - formRef.current?.elements.namedItem("skipCrc") as HTMLInputElement - ).checked - ? "Transferring image to the node..." - : "Checking CRC and transferring image to the node..."; - setStatusMessage(msg); - - // Update progress bar using bytes_written from Transferring data - const bytesWritten = data.Transferring.bytes_written ?? 0; + setStatusMessage( + (formRef.current?.elements.namedItem("skipCrc") as HTMLInputElement) + .checked + ? `Transferring image to the ${nodeWord}...` + : `Checking CRC and transferring image to the ${nodeWord}...` + ); setProgress({ - transferred: `${filesize(bytesWritten, { standard: "jedec" })} written`, + transferred: `${filesize(data.Transferring.bytes_written ?? 0, { standard: "jedec" })} written`, total: undefined, pct: 100, }); } else if (isFlashing && data?.Done) { setIsFlashing(false); - const msg = "Image flashed successfully to the node"; + const msg = `Image flashed successfully to the ${nodeWord}`; setStatusMessage(msg); toast({ title: "Flashing successful", description: msg }); } @@ -104,8 +107,6 @@ function Flash() { setConfirmFlashModal(false); const form = formRef.current; - const nodeId = (form.elements.namedItem("node") as HTMLSelectElement) - .selectedOptions[0].value; const file = (form.elements.namedItem("file") as HTMLInputElement) .files?.[0]; const url = (form.elements.namedItem("file-url") as HTMLInputElement) @@ -115,19 +116,22 @@ function Flash() { const skipCRC = (form.elements.namedItem("skipCrc") as HTMLInputElement) .checked; - setStatusMessage(`Transferring image to node ${nodeId + 1}...`); + // If more than one node is selected, batch flashing is enabled + const batch = + selectedNodes.length > 1 ? selectedNodes.slice(1) : undefined; + + setStatusMessage(`Transferring image to selected ${nodeWord}...`); mutateNodeUpdate( - { nodeId: Number.parseInt(nodeId), file, url, sha256, skipCRC }, + { nodeId: selectedNodes[0], batch, file, url, sha256, skipCRC }, { onSuccess: () => { void refetch(); }, - onError: () => { - const msg = `Failed to transfer the image to node ${nodeId + 1}`; - setStatusMessage(msg); + onError: (err) => { + setStatusMessage(err.message); toast({ title: "Flashing failed", - description: msg, + description: `Failed to transfer the image to selected ${nodeWord}`, variant: "destructive", }); }, @@ -137,21 +141,48 @@ function Flash() { }; return ( - +
- +
+