refactor-ota-dialog (#768)

Co-authored-by: philon- <philon-@users.noreply.github.com>
This commit is contained in:
Jeremy Gallant
2025-08-10 13:49:42 +02:00
committed by GitHub
parent 27ed4e58bd
commit 1dbf0b07b6
11 changed files with 202 additions and 190 deletions

View File

@@ -626,7 +626,7 @@ export class MeshDevice {
public async reboot(time: number): Promise<number> {
this.log.debug(
Emitter[Emitter.Reboot],
`🔌 Rebooting node ${time > 0 ? "now" : `in ${time} seconds`}`,
`🔌 Rebooting node ${time === 0 ? "now" : `in ${time} seconds`}`,
);
const reboot = create(Protobuf.Admin.AdminMessageSchema, {
@@ -649,7 +649,7 @@ export class MeshDevice {
public async rebootOta(time: number): Promise<number> {
this.log.debug(
Emitter[Emitter.RebootOta],
`🔌 Rebooting into OTA mode ${time > 0 ? "now" : `in ${time} seconds`}`,
`🔌 Rebooting into OTA mode ${time === 0 ? "now" : `in ${time} seconds`}`,
);
const rebootOta = create(Protobuf.Admin.AdminMessageSchema, {

View File

@@ -33,8 +33,7 @@
"qrGenerator": "Generator",
"qrImport": "Import",
"scheduleShutdown": "Schedule Shutdown",
"scheduleReboot": "Schedule Reboot",
"rebootToOtaMode": "Reboot To OTA Mode",
"scheduleReboot": "Reboot Device",
"resetNodeDb": "Reset Node DB",
"factoryResetDevice": "Factory Reset Device",
"factoryResetConfig": "Factory Reset Config",

View File

@@ -17,7 +17,6 @@
"now": "Now",
"ok": "OK",
"print": "Print",
"rebootOtaNow": "Reboot to OTA Mode Now",
"remove": "Remove",
"requestNewKeys": "Request New Keys",
"requestPosition": "Request Position",

View File

@@ -132,15 +132,15 @@
"sharableUrl": "Sharable URL",
"title": "Generate QR Code"
},
"rebootOta": {
"title": "Schedule Reboot",
"description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.",
"enterDelay": "Enter delay (sec)",
"scheduled": "Reboot has been scheduled"
},
"reboot": {
"title": "Schedule Reboot",
"description": "Reboot the connected node after x minutes."
"title": "Reboot device",
"description": "Reboot now or schedule a reboot of the connected node. Optionally, you can choose to reboot into OTA (Over-the-Air) mode.",
"ota": "Reboot into OTA mode",
"enterDelay": "Enter delay",
"scheduled": "Reboot has been scheduled",
"schedule": "Schedule reboot",
"now": "Reboot now",
"cancel": "Cancel scheduled reboot"
},
"refreshKeys": {
"description": {

View File

@@ -189,13 +189,6 @@ export const CommandPalette = () => {
setDialogOpen("reboot", true);
},
},
{
label: t("contextual.command.rebootToOtaMode"),
icon: RefreshCwIcon,
action() {
setDialogOpen("rebootOTA", true);
},
},
{
label: t("contextual.command.resetNodeDb"),
icon: TrashIcon,

View File

@@ -5,7 +5,6 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RemoveNodeDialog } from "@components/Dialog/RemoveNodeDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
@@ -79,12 +78,6 @@ export const DialogManager = () => {
setDialogOpen("refreshKeys", open);
}}
/>
<RebootOTADialog
open={dialog.rebootOTA}
onOpenChange={(open) => {
setDialogOpen("rebootOTA", open);
}}
/>
<DeleteMessagesDialog
open={dialog.deleteMessages}
onOpenChange={(open) => {

View File

@@ -7,10 +7,15 @@ import type {
} from "react";
import type { JSX } from "react/jsx-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RebootOTADialog } from "./RebootOTADialog.tsx";
import { RebootDialog } from "./RebootDialog.tsx";
const rebootMock = vi.fn();
const rebootOtaMock = vi.fn();
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
let mockConnection: {
rebootOta: (delay: number) => void,
reboot: (delay: number) => void
} | undefined = {
reboot: rebootMock,
rebootOta: rebootOtaMock,
};
@@ -61,7 +66,7 @@ vi.mock("@components/UI/Dialog.tsx", () => {
};
});
describe("RebootOTADialog", () => {
describe("RebootDialog", () => {
beforeEach(() => {
vi.useFakeTimers();
rebootOtaMock.mockClear();
@@ -72,44 +77,68 @@ describe("RebootOTADialog", () => {
});
it("renders dialog with default input value", () => {
render(<RebootOTADialog open onOpenChange={() => {}} />);
render(<RebootDialog open onOpenChange={() => {}} />);
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
expect(
screen.getByRole("heading", { name: /schedule reboot/i, level: 1 }),
screen.getByRole("heading", { name: /reboot device/i, level: 1 }),
).toBeInTheDocument();
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /reboot now/i })).toBeInTheDocument();
});
it("calls correct reboot function based on OTA checkbox state", () => {
render(<RebootDialog open onOpenChange={() => {}} />);
// Schedule non-OTA reboot
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(5);
expect(rebootOtaMock).not.toHaveBeenCalled();
rebootMock.mockClear();
rebootOtaMock.mockClear();
// Cancel scheduled
fireEvent.click(screen.getByTestId("cancelRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(-1);
expect(rebootOtaMock).not.toHaveBeenCalled();
rebootMock.mockClear();
rebootOtaMock.mockClear();
// Schedule OTA reboot
fireEvent.click(screen.getByText(/reboot into ota mode/i));
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootOtaMock).toHaveBeenCalledWith(5);
expect(rebootMock).not.toHaveBeenCalled();
});
it("schedules a reboot with delay and calls rebootOta", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: "3" },
});
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(3);
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
vi.advanceTimersByTime(3000);
await waitFor(() => {
expect(rebootOtaMock).toHaveBeenCalledWith(0);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
it("triggers an instant reboot", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
fireEvent.click(screen.getByRole("button", { name: /reboot now/i }));
await waitFor(() => {
expect(rebootOtaMock).toHaveBeenCalledWith(5);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
expect(rebootMock).toHaveBeenCalledWith(0);
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
});
it("does not call reboot if connection is undefined", async () => {
@@ -117,16 +146,30 @@ describe("RebootOTADialog", () => {
mockConnection = undefined;
render(<RebootOTADialog open onOpenChange={onOpenChangeMock} />);
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
vi.advanceTimersByTime(5000);
await waitFor(() => {
expect(rebootOtaMock).not.toHaveBeenCalled();
expect(onOpenChangeMock).not.toHaveBeenCalled();
});
expect(rebootMock).not.toHaveBeenCalled();
expect(rebootOtaMock).not.toHaveBeenCalled();
mockConnection = { rebootOta: rebootOtaMock };
mockConnection = { reboot: rebootMock, rebootOta: rebootOtaMock };
});
it("cancels a scheduled reboot and calls rebootOta with -1", async () => {
const onOpenChangeMock = vi.fn();
render(<RebootDialog open onOpenChange={onOpenChangeMock} />);
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
target: { value: "4" },
});
fireEvent.click(screen.getByTestId("scheduleRebootBtn"));
expect(rebootMock).toHaveBeenCalledWith(4);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(rebootMock).toHaveBeenCalledWith(-1);
expect(screen.queryByText(/reboot has been scheduled/i)).not.toBeInTheDocument();
});
});

View File

@@ -8,21 +8,82 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { RefreshCwIcon } from "lucide-react";
import { ClockIcon, OctagonXIcon, RefreshCwIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Checkbox } from "../UI/Checkbox/index.tsx";
export interface RebootDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false);
const [isOTA, setIsOTA] = useState(false);
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>();
const [time, setTime] = useState<number>(5);
const handleReboot = (delay: number) => {
if (!connection) {
return;
}
if (isOTA) {
connection.rebootOta(delay);
} else {
connection.reboot(delay);
}
};
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) {
e.preventDefault();
return;
}
const val = e.target.value;
setInputValue(val);
const parsed = Number(val);
if (!Number.isNaN(parsed) && parsed > 0) {
setTime(parsed);
}
};
const handleRebootWithTimeout = async () => {
setIsScheduled(true);
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
handleReboot(delay);
const id = setTimeout(() => {
setIsScheduled(false);
onOpenChange(false);
setInputValue(DEFAULT_REBOOT_DELAY.toString());
}, delay * 1000);
setTimeoutId(id);
};
const handleCancel = () => {
clearTimeout(timeoutId);
setIsScheduled(false);
handleReboot(-1);
};
const handleInstantReboot = async () => {
handleReboot(0);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -32,24 +93,66 @@ export const RebootDialog = ({ open, onOpenChange }: RebootDialogProps) => {
<DialogTitle>{t("reboot.title")}</DialogTitle>
<DialogDescription>{t("reboot.description")}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-4">
<Input
type="number"
className="dark:text-slate-900"
value={time}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
/>
<Button
className="w-24"
name="now"
onClick={() => {
connection?.reboot(2).then(() => onOpenChange(false));
}}
>
<RefreshCwIcon className="mr-2" size={16} />
{t("button.now")}
</Button>
</div>
<Separator />
{!isScheduled ? (
<>
<Checkbox
checked={isOTA}
onChange={(checked) => setIsOTA(checked)}
className="px-2"
>
{t("reboot.ota")}
</Checkbox>
<div className="flex gap-2 px-2 items-center relative">
<Input
type="number"
min={1}
max={86400}
value={inputValue}
onChange={handleSetTime}
placeholder={t("reboot.enterDelay")}
suffix={t("unit.second.plural")}
/>
<Button
onClick={() => handleRebootWithTimeout()}
data-testid="scheduleRebootBtn"
className="w-9/12"
>
<ClockIcon className="mr-2" size={18} />
{t("reboot.schedule")}
</Button>
</div>
<div className="px-2">
<Button
variant="destructive"
name="rebootNow"
onClick={() => handleInstantReboot()}
className=" w-full"
>
<RefreshCwIcon className="mr-2" size={16} />
{t("reboot.now")}
</Button>
</div>
</>
) : (
<div className="px-2">
<div className="pb-6 pt-2 text-center">
<Label className=" text-gray-700 dark:text-gray-300 ">
{t("reboot.scheduled")}
</Label>
</div>
<Button
variant="destructive"
name="cancelReboot"
onClick={() => handleCancel()}
className=" w-full"
data-testid="cancelRebootBtn"
>
<OctagonXIcon className="mr-2" size={16} />
{t("reboot.cancel")}
</Button>
</div>
)}
</DialogContent>
</Dialog>
);

View File

@@ -1,117 +0,0 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export interface RebootOTADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const DEFAULT_REBOOT_DELAY = 5; // seconds
export const RebootOTADialog = ({
open,
onOpenChange,
}: RebootOTADialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
const [isScheduled, setIsScheduled] = useState(false);
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) {
e.preventDefault();
return;
}
const val = e.target.value;
setInputValue(val);
const parsed = Number(val);
if (!Number.isNaN(parsed) && parsed > 0) {
setTime(parsed);
}
};
const handleRebootWithTimeout = async () => {
if (!connection) {
return;
}
setIsScheduled(true);
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, delay * 1000);
}).finally(() => {
setIsScheduled(false);
onOpenChange(false);
setInputValue(DEFAULT_REBOOT_DELAY.toString());
});
connection.rebootOta(0);
};
const handleInstantReboot = async () => {
if (!connection) {
return;
}
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("rebootOta.title")}</DialogTitle>
<DialogDescription>{t("rebootOta.description")}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 p-2 items-center relative">
<Input
type="number"
min={1}
max={86400}
className="dark:text-slate-900 appearance-none"
value={inputValue}
onChange={handleSetTime}
placeholder={t("rebootOta.enterDelay")}
/>
<Button
onClick={() => handleRebootWithTimeout()}
data-testid="scheduleRebootBtn"
className="w-9/12"
>
<ClockIcon className="mr-2" size={18} />
{isScheduled ? t("rebootOta.scheduled") : t("rebootOta.title")}
</Button>
</div>
<Button
variant="destructive"
name="rebootNow"
onClick={() => handleInstantReboot()}
>
<RefreshCwIcon className="mr-2" size={16} />
{t("button.rebootOtaNow")}
</Button>
</DialogContent>
</Dialog>
);
};

View File

@@ -36,7 +36,6 @@ export const mockDeviceStore: Device = {
QR: false,
shutdown: false,
reboot: false,
rebootOTA: false,
deviceName: false,
nodeRemoval: false,
pkiBackup: false,
@@ -86,4 +85,6 @@ export const mockDeviceStore: Device = {
sendAdminMessage: vi.fn(),
updateFavorite: vi.fn(),
updateIgnored: vi.fn(),
getAllUnreadCount: vi.fn().mockReturnValue(0),
getUnreadCount: vi.fn().mockReturnValue(0),
};

View File

@@ -54,7 +54,6 @@ export interface Device {
QR: boolean;
shutdown: boolean;
reboot: boolean;
rebootOTA: boolean;
deviceName: boolean;
nodeRemoval: boolean;
pkiBackup: boolean;
@@ -172,7 +171,6 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
nodeDetails: false,
unsafeRoles: false,
refreshKeys: false,
rebootOTA: false,
deleteMessages: false,
managedMode: false,
},