Skip to content

Commit

Permalink
feat: add initial batch flashing support
Browse files Browse the repository at this point in the history
  • Loading branch information
barrenechea committed May 23, 2024
1 parent c610757 commit 57ab4d9
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 60 deletions.
31 changes: 29 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bmc-ui",
"version": "3.1.0",
"version": "3.2.0",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions src/components/ui/toggle.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Root>,
React.ComponentPropsWithoutRef<typeof Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));

Toggle.displayName = Root.displayName;

export { Toggle };
7 changes: 7 additions & 0 deletions src/lib/api/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const handleParams = (variables: {
sha256?: string;
skipCRC?: boolean;
node?: number;
batch?: number[];
}) => {
const params: Record<string, unknown> = {
opt: "set",
Expand All @@ -35,6 +36,10 @@ const handleParams = (variables: {
params.node = variables.node;
}

if (variables.batch !== undefined) {
params.batch = variables.batch.join(",");
}

return params;
};

Expand Down Expand Up @@ -114,6 +119,7 @@ export function useNodeUpdateMutation(
mutationKey: ["nodeUpdateMutation"],
mutationFn: async (variables: {
nodeId: number;
batch?: number[];
file?: File;
url?: string;
sha256?: string;
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/lib/api/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ interface PowerTabResponse {
}

interface AboutTabResponse {
model: string;
board_model: string;
board_revision: string;
hostname: string;
api: string;
version: string;
Expand Down
4 changes: 3 additions & 1 deletion src/routes/_tabLayout/about.lazy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ function About() {
return (
<TabView>
<dl className="flex flex-col">
<TableItem term="Board model">{data.model}</TableItem>
<TableItem term="Board model">
{data.board_model} (v{data.board_revision})
</TableItem>
<TableItem term="Host name">{data.hostname}</TableItem>
<TableItem term="Daemon version">{`v${data.version}`}</TableItem>
<TableItem term="Build time">
Expand Down
141 changes: 86 additions & 55 deletions src/routes/_tabLayout/flash-node.lazy.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,48 @@
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";
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<number[]>([]);
const nodeWord = useMemo(
() => (selectedNodes.length === 1 ? "node" : "nodes"),
[selectedNodes]
);

const { toast } = useToast();
const formRef = useRef<HTMLFormElement>(null);
const [confirmFlashModal, setConfirmFlashModal] = useState(false);
Expand All @@ -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
Expand All @@ -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(() => {
Expand All @@ -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 });
}
Expand All @@ -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)
Expand All @@ -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",
});
},
Expand All @@ -137,21 +141,48 @@ function Flash() {
};

return (
<TabView title="Install an OS image on a selected node">
<TabView
title={
batchFlashingSupport
? "Install an OS image on selected nodes"
: "Install an OS image on a selected node"
}
>
<form ref={formRef}>
<div className="mb-4">
<Select name="node">
<SelectTrigger label="Selected node">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
<div className="mb-2 flex flex-wrap items-center gap-4">
<span className="text-sm font-semibold">
{batchFlashingSupport
? "Select the nodes to flash:"
: "Select a node to flash:"}
</span>
<div className="flex gap-2">
{nodeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<Toggle
key={option.value}
aria-label={`Toggle flash node ${option.value}`}
className="text-xs md:text-sm"
pressed={selectedNodes.includes(option.value)}
onPressedChange={(pressed) =>
setSelectedNodes((prevNodes) => {
if (pressed) {
return batchFlashingSupport
? [...prevNodes, option.value].sort()
: [option.value];
} else {
return prevNodes.filter(
(nodeValue) => nodeValue !== option.value
);
}
})
}
>
<Cpu className="mr-2 size-4" />
{option.label}
</SelectItem>
</Toggle>
))}
</SelectContent>
</Select>
</div>
</div>
</div>

<div className="mb-4">
Expand Down

0 comments on commit 57ab4d9

Please sign in to comment.