Skip to content

Commit

Permalink
feat(2.5): Node 1 USB-A compatibility mode (#14)
Browse files Browse the repository at this point in the history
* feat: query usbNode1 data, render checkbox

* feat: trigger mutation for usb_node1 on form submit

* validation: deselect and disable checkbox if attempting to flash node 1

* i18n: add translated keys by Claude

* chore: bump version, allow local development with proxied tpi

* fix: misleading comment

* fix: query param for set function
  • Loading branch information
barrenechea authored Jul 18, 2024
1 parent 7618c82 commit 5848299
Show file tree
Hide file tree
Showing 13 changed files with 206 additions and 51 deletions.
45 changes: 36 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,58 @@ BMC-UI is a web-based user interface for managing and configuring the BMC of a T

## Getting Started

1. Set up `bmcd-api-mock` for development:

- Clone the [bmcd-api-mock](https://github.com/barrenechea/bmcd-api-mock) repository.
- Follow the instructions in the `bmcd-api-mock` repository to set up and run the mock server.

2. Clone the repository:
1. Clone the repository:

```bash
git clone https://github.com/turing-machines/BMC-UI.git
```

3. Install dependencies:
2. Install dependencies:

```bash
cd BMC-UI
npm install
```

4. Start the development server:
3. Start the development server:

There are multiple ways to run the development server:

a. Connect to a local Turing Pi cluster (default):

```bash
npm run dev
```

5. Open your browser and visit `http://localhost:5173` to see the application running.
This will connect to `https://turingpi.local` for the API by default.

b. Connect to a specific Turing Pi cluster:

If your Turing Pi cluster is using a different hostname, domain or IP address, you can specify it using the `CLUSTER_URL` environment variable:

```bash
CLUSTER_URL=https://your-cluster.lan npm run dev
```

or

```bash
CLUSTER_URL=https://192.168.1.100 npm run dev
```

c. Use bmcd-api-mock:

If you want to use [bmcd-api-mock](https://github.com/barrenechea/bmcd-api-mock) as the API for development:

- Clone and set up the bmcd-api-mock repository.
- Run the mock server (usually on `http://localhost:4460`).
- Start the BMC-UI development server with the CLUSTER_URL environment variable:

```bash
CLUSTER_URL=http://localhost:4460 npm run dev
```

4. Open your browser and visit `http://localhost:5173` to see the application running.

## Deployment

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bmc-ui",
"version": "3.2.0",
"version": "3.3.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
19 changes: 19 additions & 0 deletions src/lib/api/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface APIResponse<T> {
}

interface USBTabResponse {
bus_type: "Single bus" | "Usb hub";
mode: "Host" | "Device" | "Flash";
node: "Node 1" | "Node 2" | "Node 3" | "Node 4";
route: "Bmc" | "AlternativePort";
Expand Down Expand Up @@ -205,3 +206,21 @@ export function useCoolingDevicesQuery() {
},
});
}

export function useUSBNode1Query() {
const api = useAxiosWithAuth();

return useSuspenseQuery({
queryKey: ["usbNode1"],
queryFn: async () => {
const response = await api.get<APIResponse<boolean>>("/bmc", {
params: {
opt: "get",
type: "usb_node1",
},
});

return response.data.response[0].result;
},
});
}
22 changes: 22 additions & 0 deletions src/lib/api/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,25 @@ export function useCoolingDeviceMutation() {
},
});
}

export function useUSBNode1Mutation() {
const api = useAxiosWithAuth();
const queryClient = useQueryClient();

return useMutation({
mutationKey: ["usbNode1Mutation"],
mutationFn: async (variables: { alternative_port: boolean }) => {
const response = await api.get<APIResponse<string>>("/bmc", {
params: {
opt: "set",
type: "usb_node1",
alternative_port: variables.alternative_port ? "" : null,
},
});
return response.data.response[0].result;
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["usbNode1"] });
},
});
}
5 changes: 5 additions & 0 deletions src/locale/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ const translations = {
"Versetzt das Modul in den Flash-Modus und setzt den USB_OTG in den Gerätemodus.",
flashUsage:
"Verwenden Sie diese Option, um das Modul über den USB_OTG-Port zu flashen.",
usbNode1: "Node 1 USB-A Kompatibilitätsmodus",
usbNode1Definition:
"Durch Auswahl dieser Option wird die primäre USB-Schnittstelle von Node 1 zum USB-A-Anschluss geleitet. Einige Module wie Raspberry Pi CM4 haben keine sekundäre Schnittstelle und funktionieren daher nicht.",
usbNode1Usage:
"Verwenden Sie diese Option, wenn USB-Geräte beim Einstecken nicht erkannt werden.",
},
},
firmwareUpgrade: {
Expand Down
5 changes: 5 additions & 0 deletions src/locale/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ const translations = {
flashDefinition:
"Turns the module into flashing mode and sets the USB_OTG into device mode.",
flashUsage: "Use to flash the module using USB_OTG port.",
usbNode1: "Node 1 USB-A compatibility mode",
usbNode1Definition:
"By selecting this option the primary USB interface of Node 1 gets routed to the USB-A port. Some modules don't have a secondary interface, such as Raspberry Pi CM4's and will therefore not work.",
usbNode1Usage:
"Use this option if USB devices are not detected when plugging in.",
},
},
firmwareUpgrade: {
Expand Down
5 changes: 5 additions & 0 deletions src/locale/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ const translations = {
flashDefinition:
"Convierte el módulo en modo de flasheo y establece el USB_OTG en modo dispositivo.",
flashUsage: "Usa para flashear el módulo usando el puerto USB_OTG.",
usbNode1: "Modo de compatibilidad USB-A del Nodo 1",
usbNode1Definition:
"Al seleccionar esta opción, la interfaz USB principal del Nodo 1 se enruta al puerto USB-A. Algunos módulos como Raspberry Pi CM4 no tienen una interfaz secundaria y, por lo tanto, no funcionarán.",
usbNode1Usage:
"Use esta opción si los dispositivos USB no se detectan al conectarlos.",
},
},
firmwareUpgrade: {
Expand Down
5 changes: 5 additions & 0 deletions src/locale/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ const translations = {
flashDefinition:
"Zet de module in flashmodus en stelt de USB_OTG in op apparaatmodus.",
flashUsage: "Gebruik om de module te flashen via de USB_OTG-poort.",
usbNode1: "Node 1 USB-A compatibiliteitsmodus",
usbNode1Definition:
"Door deze optie te selecteren, wordt de primaire USB-interface van Node 1 naar de USB-A-poort geleid. Sommige modules zoals Raspberry Pi CM4 hebben geen secundaire interface en werken daarom niet.",
usbNode1Usage:
"Gebruik deze optie als USB-apparaten niet worden gedetecteerd bij het aansluiten.",
},
},
firmwareUpgrade: {
Expand Down
5 changes: 5 additions & 0 deletions src/locale/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ const translations = {
flashDefinition:
"Przełącza moduł w tryb flashowania i ustawia USB_OTG w tryb urządzenia.",
flashUsage: "Użyj do flashowania modułu przez port USB_OTG.",
usbNode1: "Tryb kompatybilności USB-A dla Węzła 1",
usbNode1Definition:
"Wybranie tej opcji powoduje przekierowanie głównego interfejsu USB Węzła 1 do portu USB-A. Niektóre moduły, takie jak Raspberry Pi CM4, nie mają interfejsu dodatkowego i dlatego nie będą działać.",
usbNode1Usage:
"Użyj tej opcji, jeśli urządzenia USB nie są wykrywane po podłączeniu.",
},
},
firmwareUpgrade: {
Expand Down
4 changes: 4 additions & 0 deletions src/locale/zh-Hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ const translations = {
flash: "刷写",
flashDefinition: "将模块转换为刷写模式,并将 USB_OTG 设置为设备模式。",
flashUsage: "用于使用 USB_OTG 端口刷写模块。",
usbNode1: "节点1 USB-A 兼容模式",
usbNode1Definition:
"选择此选项会将节点1的主USB接口路由到USB-A端口。某些模块(如Raspberry Pi CM4)没有辅助接口,因此将无法工作。",
usbNode1Usage: "如果插入时无法检测到USB设备,请使用此选项。",
},
},
firmwareUpgrade: {
Expand Down
126 changes: 89 additions & 37 deletions src/routes/_tabLayout/usb.lazy.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import { InfoIcon } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";

import USBSkeleton from "@/components/skeletons/usb";
import TabView from "@/components/TabView";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
Expand All @@ -18,8 +20,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useToast } from "@/hooks/use-toast";
import { useUSBTabData } from "@/lib/api/get";
import { useUSBModeMutation } from "@/lib/api/set";
import { useUSBNode1Query, useUSBTabData } from "@/lib/api/get";
import { useUSBModeMutation, useUSBNode1Mutation } from "@/lib/api/set";

export const Route = createLazyFileRoute("/_tabLayout/usb")({
component: USB,
Expand Down Expand Up @@ -49,48 +51,60 @@ function USB() {
const { t } = useTranslation();
const { toast } = useToast();
const { data } = useUSBTabData();
const { isPending, mutate: mutateUSBMode } = useUSBModeMutation();
const { isPending: isPendingUSBMode, mutateAsync: mutateUSBMode } =
useUSBModeMutation();
const { data: usbNode1 } = useUSBNode1Query();
const { isPending: isPendingUSBNode1, mutateAsync: mutateUSBNode1 } =
useUSBNode1Mutation();

const handleSubmit = (e: React.FormEvent) => {
const [selectedMode, setSelectedMode] = useState(
modeOptions.find((option) => option.serverValue === data.mode)?.value ?? ""
);
const [selectedNode, setSelectedNode] = useState(
nodeOptions.find((option) => option.label === data.node)?.value ?? ""
);
const [isUsbNode1Checked, setIsUsbNode1Checked] = useState(usbNode1);

useEffect(() => {
// When the user chooses flash mode for node 1, the checkbox needs to be unchecked.
if (selectedMode === "2" && selectedNode === "0") {
setIsUsbNode1Checked(false);
}
}, [selectedMode, selectedNode]);

const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const form = e.target as HTMLFormElement;

const node = Number.parseInt(
(form.elements.namedItem("node") as HTMLInputElement).value
);
const mode = Number.parseInt(
(form.elements.namedItem("mode") as HTMLInputElement).value
);
mutateUSBMode(
{ node, mode },
{
onSuccess: () => {
toast({
title: t("usb.changeSuccessTitle"),
description: t("usb.changeSuccessMessage"),
});
},
onError: (e) => {
toast({
title: t("usb.changeFailedTitle"),
description: e.message,
variant: "destructive",
});
},
try {
if (usbNode1 !== isUsbNode1Checked) {
await mutateUSBNode1({ alternative_port: isUsbNode1Checked });
}
);
await mutateUSBMode({
node: Number.parseInt(selectedNode),
mode: Number.parseInt(selectedMode),
});

toast({
title: t("usb.changeSuccessTitle"),
description: t("usb.changeSuccessMessage"),
});
} catch (e) {
toast({
title: t("usb.changeFailedTitle"),
description: (e as Error).message,
variant: "destructive",
});
}
};

return (
<TabView title={t("usb.header")}>
<form onSubmit={handleSubmit}>
<form onSubmit={(e) => void handleSubmit(e)}>
<div className="space-y-4">
<Select
name="mode"
defaultValue={
modeOptions.find((option) => option.serverValue === data.mode)
?.value
}
value={selectedMode}
onValueChange={(value) => setSelectedMode(value)}
>
<SelectTrigger label={t("usb.modeSelect")}>
<SelectValue placeholder={t("ui.selectPlaceholder")} />
Expand All @@ -105,9 +119,8 @@ function USB() {
</Select>
<Select
name="node"
defaultValue={
nodeOptions.find((option) => option.label === data.node)?.value
}
value={selectedNode}
onValueChange={(value) => setSelectedNode(value)}
>
<SelectTrigger label={t("usb.nodeSelect")}>
<SelectValue placeholder={t("ui.selectPlaceholder")} />
Expand All @@ -122,9 +135,48 @@ function USB() {
))}
</SelectContent>
</Select>
{data.bus_type === "Usb hub" && (
<div className="mb-4 flex items-center">
<Checkbox
id="usbHub"
name="usbHub"
checked={isUsbNode1Checked}
onCheckedChange={(checked) =>
setIsUsbNode1Checked(checked as boolean)
}
disabled={selectedMode === "2" && selectedNode === "0"}
aria-label={t("usb.mode.usbNode1")}
/>
<label
htmlFor="usbHub"
className="not-sr-only ml-2 text-sm font-semibold"
>
{t("usb.mode.usbNode1")}
</label>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="ml-1 size-4" />
</TooltipTrigger>
<TooltipContent sideOffset={16}>
<div className="my-1 flex max-w-sm flex-col text-pretty">
<p className="font-semibold">{t("usb.mode.usbNode1")}</p>
<p>{t("usb.mode.usbNode1Definition")}</p>
<p className="mt-1 font-semibold">
{t("usb.mode.usageWord")}
</p>
<p>{t("usb.mode.usbNode1Usage")}</p>
</div>
</TooltipContent>
</Tooltip>
</div>
)}
</div>
<div className="mt-4 flex flex-row flex-wrap justify-between">
<Button type="submit" isLoading={isPending} disabled={isPending}>
<Button
type="submit"
isLoading={isPendingUSBMode || isPendingUSBNode1}
disabled={isPendingUSBMode || isPendingUSBNode1}
>
{t("usb.submitButton")}
</Button>

Expand Down
Loading

0 comments on commit 5848299

Please sign in to comment.