From 3b807e8907a6838bc1ff6bbf7da71cfe6f6b5823 Mon Sep 17 00:00:00 2001 From: Sebastian Barrenechea Date: Tue, 4 Jun 2024 14:46:12 -0400 Subject: [PATCH] fix: flashing to continue on tab switching (#11) --- package-lock.json | 4 +- package.json | 2 +- src/app.tsx | 11 +- src/components/navigation-links.tsx | 72 ++++- src/contexts/FlashContext.tsx | 274 ++++++++++++++++++ src/hooks/use-flash.ts | 11 + src/lib/api/get.ts | 5 +- .../_tabLayout/firmware-upgrade.lazy.tsx | 153 ++-------- src/routes/_tabLayout/flash-node.lazy.tsx | 129 +++------ 9 files changed, 425 insertions(+), 236 deletions(-) create mode 100644 src/contexts/FlashContext.tsx create mode 100644 src/hooks/use-flash.ts diff --git a/package-lock.json b/package-lock.json index 8dc8765..ebdd6f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bmc-ui", - "version": "3.1.1", + "version": "3.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bmc-ui", - "version": "3.1.1", + "version": "3.1.2", "dependencies": { "@fontsource/inter": "^5.0.18", "@radix-ui/react-checkbox": "^1.0.4", diff --git a/package.json b/package.json index 6af623a..5cb9275 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bmc-ui", - "version": "3.1.1", + "version": "3.1.2", "private": true, "type": "module", "scripts": { diff --git a/src/app.tsx b/src/app.tsx index 2543ee2..9e27492 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -6,6 +6,7 @@ import ReactDOM from "react-dom/client"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AuthProvider } from "@/contexts/AuthContext"; +import { FlashProvider } from "@/contexts/FlashContext"; import InnerApp from "@/innerApp"; import { type router } from "@/router"; @@ -27,10 +28,12 @@ if (!rootElement.innerHTML) { - - - - + + + + + + diff --git a/src/components/navigation-links.tsx b/src/components/navigation-links.tsx index 826a532..1d7e943 100644 --- a/src/components/navigation-links.tsx +++ b/src/components/navigation-links.tsx @@ -2,6 +2,9 @@ import { Link, type LinkProps } from "@tanstack/react-router"; import { ArrowRightIcon } from "lucide-react"; import { type ReactNode, useMemo } from "react"; +import { useFlash } from "@/hooks/use-flash"; +import { cn } from "@/lib/utils"; + const navigationLinks = [ { to: "/info", label: "Info" }, { to: "/nodes", label: "Nodes" }, @@ -11,15 +14,24 @@ const navigationLinks = [ { to: "/about", label: "About" }, ] as const; +interface FlashingLinkProps { + isFlashing: boolean; +} + function MobileLink({ to, children, onClick, -}: LinkProps & { onClick?: () => void; children: ReactNode }) { + isFlashing, +}: LinkProps & + FlashingLinkProps & { onClick?: () => void; children: ReactNode }) { return ( void; }) { + const { flashType, isFlashing } = useFlash(); + const renderLinks = useMemo( () => - navigationLinks.map(({ to, label }) => ( - - {label} - - )), - [] + navigationLinks.map(({ to, label }) => { + const isNodeFlashing = + isFlashing && flashType === "node" && to === "/flash-node"; + const isFirmwareFlashing = + isFlashing && flashType === "firmware" && to === "/firmware-upgrade"; + + return ( + + {label} + + ); + }), + [isFlashing, flashType] ); const renderMobileLinks = useMemo( () => - navigationLinks.map(({ to, label }) => ( - - {label} - - )), - [] + navigationLinks.map(({ to, label }) => { + const isNodeFlashing = + isFlashing && flashType === "node" && to === "/flash-node"; + const isFirmwareFlashing = + isFlashing && flashType === "firmware" && to === "/firmware-upgrade"; + + return ( + + {label} + + ); + }), + [isFlashing, flashType, onClick] ); if (isDesktop) diff --git a/src/contexts/FlashContext.tsx b/src/contexts/FlashContext.tsx new file mode 100644 index 0000000..80de6ae --- /dev/null +++ b/src/contexts/FlashContext.tsx @@ -0,0 +1,274 @@ +import { type AxiosProgressEvent } from "axios"; +import { filesize } from "filesize"; +import React, { + createContext, + type ReactNode, + useCallback, + useEffect, + useState, +} from "react"; + +import RebootModal from "@/components/RebootModal"; +import { toast } from "@/hooks/use-toast"; +import { + useFirmwareUpdateMutation, + useNodeUpdateMutation, +} from "@/lib/api/file"; +import { + type FlashStatus, + useFirmwareStatusQuery, + useFlashStatusQuery, +} from "@/lib/api/get"; +import { useRebootBMCMutation } from "@/lib/api/set"; + +type FlashType = "firmware" | "node" | null; + +interface FlashContextValue { + flashType: FlashType; + setFlashType: React.Dispatch>; + isFlashing: boolean; + statusMessage: string; + firmwareUpdateMutation: ReturnType; + nodeUpdateMutation: ReturnType; + firmwareStatus: ReturnType; + flashStatus: ReturnType; + uploadProgress?: { transferred: string; total: string | null; pct: number }; + handleFirmwareUpload: (variables: { + file?: File; + url?: string; + sha256?: string; + }) => Promise; + handleNodeUpdate: (variables: { + nodeId: number; + file?: File; + url?: string; + sha256?: string; + skipCRC: boolean; + }) => Promise; +} + +export const FlashContext = createContext(null); + +interface FlashProviderProps { + children: ReactNode; +} + +export const FlashProvider: React.FC = ({ children }) => { + const [flashType, setFlashType] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [isFlashing, setIsFlashing] = useState(false); + const [statusMessage, setStatusMessage] = useState(""); + const [rebootModalOpened, setRebootModalOpened] = useState(false); + const { mutate: mutateRebootBMC } = useRebootBMCMutation(); + const [uploadProgress, setUploadProgress] = + useState(); + + const uploadProgressCallback = useCallback( + (progressEvent: AxiosProgressEvent) => { + setUploadProgress({ + transferred: filesize(progressEvent.loaded ?? 0, { standard: "jedec" }), + total: progressEvent.total + ? filesize(progressEvent.total, { standard: "jedec" }) + : null, + pct: progressEvent.total + ? Math.round((progressEvent.loaded / progressEvent.total) * 100) + : 100, + }); + }, + [] + ); + + const firmwareUpdateMutation = useFirmwareUpdateMutation( + uploadProgressCallback + ); + const nodeUpdateMutation = useNodeUpdateMutation(uploadProgressCallback); + const firmwareStatus = useFirmwareStatusQuery( + flashType === "firmware" && isFlashing + ); + const flashStatus = useFlashStatusQuery(flashType === "node" && isFlashing); + + const handleRebootBMC = () => { + setRebootModalOpened(false); + mutateRebootBMC(undefined, { + onSuccess: () => { + toast({ title: "Rebooting BMC", description: "The BMC is rebooting" }); + }, + onError: (error) => { + toast({ + title: "Failed to reboot BMC", + description: error.message, + variant: "destructive", + }); + }, + }); + }; + + const handleFirmwareUpload = async (variables: { + file?: File; + url?: string; + sha256?: string; + }) => { + setFlashType("firmware"); + setIsUploading(true); + setStatusMessage("Uploading BMC firmware..."); + await firmwareUpdateMutation.mutateAsync(variables, { + onSuccess: () => { + setIsUploading(false); + setIsFlashing(true); + setStatusMessage("Writing firmware to BMC..."); + void firmwareStatus.refetch(); + }, + onError: (error) => { + setIsUploading(false); + const msg = "Failed to upload the BMC firmware"; + setStatusMessage(msg); + toast({ + title: msg, + description: error.message, + variant: "destructive", + }); + }, + }); + }; + + const handleNodeUpdate = async (variables: { + nodeId: number; + file?: File; + url?: string; + sha256?: string; + skipCRC: boolean; + }) => { + setFlashType("node"); + setIsUploading(true); + setStatusMessage(`Transferring image to node ${variables.nodeId + 1}...`); + await nodeUpdateMutation.mutateAsync(variables, { + onSuccess: () => { + setIsUploading(false); + setIsFlashing(true); + const msg = variables.skipCRC + ? "Transferring image to the node..." + : "Checking CRC and transferring image to the node..."; + setStatusMessage(msg); + void flashStatus.refetch(); + }, + onError: (error) => { + setIsUploading(false); + const msg = `Failed to transfer the image to node ${variables.nodeId + 1}`; + setStatusMessage(msg); + toast({ + title: msg, + description: error.message, + variant: "destructive", + }); + }, + }); + }; + + const handleError = useCallback((error: string, title: string) => { + setIsFlashing(false); + setUploadProgress(undefined); + setStatusMessage(error); + toast({ + title, + description: error, + variant: "destructive", + }); + }, []); + + const handleTransferProgress = useCallback((data: FlashStatus) => { + const bytesWritten = data.Transferring?.bytes_written ?? 0; + setUploadProgress({ + transferred: `${filesize(bytesWritten, { standard: "jedec" })} written`, + total: null, + pct: 100, + }); + }, []); + + const handleSuccess = useCallback((title: string, message: string) => { + setIsFlashing(false); + setUploadProgress(undefined); + setStatusMessage(message); + toast({ title, description: message }); + }, []); + + useEffect(() => { + if (!isFlashing || !flashType) return; + + if (flashType === "node") { + if (!flashStatus.isStale) { + if (flashStatus.data?.Error) { + handleError(flashStatus.data.Error, "An error has occurred"); + } else if (flashStatus.data?.Transferring) { + handleTransferProgress(flashStatus.data); + } else if (flashStatus.data?.Done) { + handleSuccess( + "Flashing successful", + "Image flashed successfully to the node" + ); + } + } + } else if (flashType === "firmware") { + if (!firmwareStatus.isStale) { + if (firmwareStatus.data?.Error) { + handleError(firmwareStatus.data.Error, "An error has occurred"); + } else if (firmwareStatus.data?.Transferring) { + handleTransferProgress(firmwareStatus.data); + } else if (firmwareStatus.data?.Done) { + handleSuccess( + "Flashing successful", + "Firmware upgrade completed successfully" + ); + setRebootModalOpened(true); + } + } + } + }, [ + isFlashing, + flashType, + flashStatus.data, + firmwareStatus.data, + firmwareStatus.isStale, + flashStatus.isStale, + handleError, + handleTransferProgress, + handleSuccess, + ]); + + return ( + + <> + {children} + setRebootModalOpened(false)} + onReboot={handleRebootBMC} + title="Upgrade Finished!" + message={ +
+

To finalize the upgrade, a system reboot is necessary.

+

Would you like to proceed with the reboot now?

+

+ The nodes will temporarily lose power until the reboot process + is complete. +

+
+ } + /> + +
+ ); +}; diff --git a/src/hooks/use-flash.ts b/src/hooks/use-flash.ts new file mode 100644 index 0000000..27525f1 --- /dev/null +++ b/src/hooks/use-flash.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; + +import { FlashContext } from "@/contexts/FlashContext"; + +export function useFlash() { + const context = useContext(FlashContext); + if (!context) { + throw new Error("useFlash must be used within an FlashProvider"); + } + return context; +} diff --git a/src/lib/api/get.ts b/src/lib/api/get.ts index 1a773fc..bb439c8 100644 --- a/src/lib/api/get.ts +++ b/src/lib/api/get.ts @@ -30,7 +30,7 @@ interface AboutTabResponse { version: string; } -interface FlashStatus { +export interface FlashStatus { Transferring?: { id: number; process_name: string; @@ -99,6 +99,7 @@ export function useAboutTabData() { return useSuspenseQuery({ queryKey: ["aboutTabData"], + staleTime: 1000 * 60 * 60, // Valid for 1 hour queryFn: async () => { const response = await api.get>("/bmc", { params: { @@ -153,6 +154,7 @@ export function useFlashStatusQuery(enabled: boolean) { return useQuery({ queryKey: ["flashStatus"], + staleTime: 1000, // Valid for 1 second queryFn: async () => { const response = await api.get("/bmc", { params: { @@ -172,6 +174,7 @@ export function useFirmwareStatusQuery(enabled: boolean) { return useQuery({ queryKey: ["firmwareStatus"], + staleTime: 1000, // Valid for 1 second queryFn: async () => { const response = await api.get("/bmc", { params: { diff --git a/src/routes/_tabLayout/firmware-upgrade.lazy.tsx b/src/routes/_tabLayout/firmware-upgrade.lazy.tsx index ecb5dba..f6b4540 100644 --- a/src/routes/_tabLayout/firmware-upgrade.lazy.tsx +++ b/src/routes/_tabLayout/firmware-upgrade.lazy.tsx @@ -1,87 +1,40 @@ import { createLazyFileRoute } from "@tanstack/react-router"; -import type { AxiosProgressEvent } from "axios"; -import { filesize } from "filesize"; import { useEffect, useRef, useState } from "react"; import ConfirmationModal from "@/components/ConfirmationModal"; -import RebootModal from "@/components/RebootModal"; import TabView from "@/components/TabView"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; -import { useToast } from "@/hooks/use-toast"; -import { useFirmwareUpdateMutation } from "@/lib/api/file"; -import { useFirmwareStatusQuery } from "@/lib/api/get"; -import { useRebootBMCMutation } from "@/lib/api/set"; +import { useFlash } from "@/hooks/use-flash"; export const Route = createLazyFileRoute("/_tabLayout/firmware-upgrade")({ component: FirmwareUpgrade, }); function FirmwareUpgrade() { - const { toast } = useToast(); const formRef = useRef(null); - const [isUpgrading, setIsUpgrading] = useState(false); const [statusMessage, setStatusMessage] = useState(""); const [confirmFlashModal, setConfirmFlashModal] = useState(false); - const [rebootModalOpened, setRebootModalOpened] = useState(false); - const [progress, setProgress] = useState<{ - transferred: string; - total?: string; - pct: number; - }>({ transferred: "", total: "", pct: 0 }); - const uploadProgressCallback = (progressEvent: AxiosProgressEvent) => { - setProgress({ - transferred: filesize(progressEvent.loaded ?? 0, { standard: "jedec" }), - total: filesize(progressEvent.total ?? 0, { standard: "jedec" }), - pct: Math.round( - ((progressEvent.loaded ?? 0) / (progressEvent.total ?? 1)) * 100 - ), - }); - }; const { - mutate: mutateFirmwareUpdate, - isIdle, - isPending, - } = useFirmwareUpdateMutation(uploadProgressCallback); - const { mutate: mutateRebootBMC } = useRebootBMCMutation(); - const { data, refetch } = useFirmwareStatusQuery(isUpgrading); + flashType, + isFlashing, + statusMessage: _statusMessage, + firmwareUpdateMutation, + uploadProgress, + handleFirmwareUpload, + } = useFlash(); useEffect(() => { - if (isUpgrading && data?.Error) { - setStatusMessage(data.Error); - toast({ - title: "An error has occurred", - description: data.Error, - variant: "destructive", - }); - setIsUpgrading(false); - } else if (!isUpgrading && data?.Transferring) { - setIsUpgrading(true); - setStatusMessage("Writing firmware to BMC..."); - - // Update progress bar using bytes_written from Transferring data - const bytesWritten = data.Transferring.bytes_written ?? 0; - setProgress({ - transferred: `${filesize(bytesWritten, { standard: "jedec" })} written`, - total: undefined, - pct: 100, - }); - } else if (isUpgrading && data?.Done) { - setIsUpgrading(false); - const msg = "Firmware upgrade completed successfully"; - setStatusMessage(msg); - toast({ title: "Upgrade successful", description: msg }); - setRebootModalOpened(true); - } - }, [data]); + _statusMessage && setStatusMessage(_statusMessage); + }, [_statusMessage]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setConfirmFlashModal(true); }; - const handleFirmwareUpload = () => { + const handleUpload = () => { if (formRef.current) { setConfirmFlashModal(false); @@ -93,43 +46,10 @@ function FirmwareUpgrade() { const sha256 = (form.elements.namedItem("sha256") as HTMLInputElement) .value; - setStatusMessage("Uploading BMC firmware..."); - mutateFirmwareUpdate( - { file, url, sha256 }, - { - onSuccess: () => { - void refetch(); - }, - onError: (e) => { - const msg = "Upgrade failed"; - setStatusMessage(msg); - toast({ - title: msg, - description: e.message, - variant: "destructive", - }); - }, - } - ); + void handleFirmwareUpload({ file, url, sha256 }); } }; - const handleRebootBMC = () => { - setRebootModalOpened(false); - mutateRebootBMC(undefined, { - onSuccess: () => { - toast({ title: "Rebooting BMC", description: "The BMC is rebooting" }); - }, - onError: (e) => { - toast({ - title: "Failed to reboot BMC", - description: e.message, - variant: "destructive", - }); - }, - }); - }; - return (
@@ -147,48 +67,37 @@ function FirmwareUpgrade() {
- {!isIdle && ( -
- -
{statusMessage}
-
+ {uploadProgress && flashType === "firmware" && ( + + )} + {flashType === "firmware" && statusMessage && ( +
{statusMessage}
)} setConfirmFlashModal(false)} - onConfirm={handleFirmwareUpload} + onConfirm={handleUpload} title="Upgrade Firmware?" message="A reboot is required to finalise the upgrade process." /> - setRebootModalOpened(false)} - onReboot={handleRebootBMC} - title="Upgrade Finished!" - message={ -
-

To finalize the upgrade, a system reboot is necessary.

-

Would you like to proceed with the reboot now?

-

- The nodes will temporarily lose power until the reboot process is - complete. -

-
- } - isPending={isPending} - />
); } diff --git a/src/routes/_tabLayout/flash-node.lazy.tsx b/src/routes/_tabLayout/flash-node.lazy.tsx index db93b28..295dc5b 100644 --- a/src/routes/_tabLayout/flash-node.lazy.tsx +++ b/src/routes/_tabLayout/flash-node.lazy.tsx @@ -1,6 +1,4 @@ import { createLazyFileRoute } from "@tanstack/react-router"; -import type { AxiosProgressEvent } from "axios"; -import { filesize } from "filesize"; import { useEffect, useRef, useState } from "react"; import ConfirmationModal from "@/components/ConfirmationModal"; @@ -16,12 +14,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { useFlash } from "@/hooks/use-flash"; import { useToast } from "@/hooks/use-toast"; -import { useNodeUpdateMutation } from "@/lib/api/file"; -import { useFlashStatusQuery } from "@/lib/api/get"; export const Route = createLazyFileRoute("/_tabLayout/flash-node")({ - component: Flash, + component: FlashNode, }); interface SelectOption { @@ -36,68 +33,25 @@ const nodeOptions: SelectOption[] = [ { value: "3", label: "Node 4" }, ]; -function Flash() { +function FlashNode() { const { toast } = useToast(); const formRef = useRef(null); const [confirmFlashModal, setConfirmFlashModal] = useState(false); + const { + flashType, + setFlashType, + isFlashing, + statusMessage: _statusMessage, + nodeUpdateMutation, + uploadProgress, + handleNodeUpdate, + } = useFlash(); - const [isFlashing, setIsFlashing] = useState(false); const [statusMessage, setStatusMessage] = useState(""); - const [progress, setProgress] = useState<{ - transferred: string; - total?: string; - pct: number; - }>({ transferred: "", total: "", pct: 0 }); - const uploadProgressCallback = (progressEvent: AxiosProgressEvent) => { - setProgress({ - transferred: filesize(progressEvent.loaded ?? 0, { standard: "jedec" }), - total: progressEvent.total - ? filesize(progressEvent.total, { standard: "jedec" }) - : undefined, - pct: progressEvent.total - ? Math.round((progressEvent.loaded / (progressEvent.total ?? 1)) * 100) - : 100, - }); - }; - const { - mutate: mutateNodeUpdate, - isIdle, - isPending, - } = useNodeUpdateMutation(uploadProgressCallback); - const { data, refetch } = useFlashStatusQuery(isFlashing); useEffect(() => { - if (isFlashing && data?.Error) { - setStatusMessage(data.Error); - toast({ - title: "An error has occurred", - description: data.Error, - variant: "destructive", - }); - 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; - setProgress({ - transferred: `${filesize(bytesWritten, { standard: "jedec" })} written`, - total: undefined, - pct: 100, - }); - } else if (isFlashing && data?.Done) { - setIsFlashing(false); - const msg = "Image flashed successfully to the node"; - setStatusMessage(msg); - toast({ title: "Flashing successful", description: msg }); - } - }, [data]); + _statusMessage && setStatusMessage(_statusMessage); + }, [_statusMessage]); const handleSubmit = () => { if (formRef.current) { @@ -115,24 +69,15 @@ function Flash() { const skipCRC = (form.elements.namedItem("skipCrc") as HTMLInputElement) .checked; - setStatusMessage(`Transferring image to node ${nodeId + 1}...`); - mutateNodeUpdate( - { nodeId: Number.parseInt(nodeId), file, url, sha256, skipCRC }, - { - onSuccess: () => { - void refetch(); - }, - onError: () => { - const msg = `Failed to transfer the image to node ${nodeId + 1}`; - setStatusMessage(msg); - toast({ - title: "Flashing failed", - description: msg, - variant: "destructive", - }); - }, - } - ); + const parsedNodeId = Number.parseInt(nodeId); + + void handleNodeUpdate({ + nodeId: parsedNodeId, + file, + url, + sha256, + skipCRC, + }); } }; @@ -171,8 +116,11 @@ function Flash() { @@ -187,16 +135,17 @@ function Flash() { - {!isIdle && ( -
- -
{statusMessage}
-
+ {uploadProgress && flashType === "node" && ( + + )} + {flashType === "node" && statusMessage && ( +
{statusMessage}
)}