Browse Source

Merge branch 'pki' into feature/security-tab

pull/277/head
Hunter Thornsberry 2 years ago
committed by GitHub
parent
commit
fce642c24e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 44
      .github/workflows/ci.yml
  2. 9
      .github/workflows/pr.yml
  3. 55
      .github/workflows/release.yml
  4. 7
      src/App.tsx
  5. 12
      src/components/Form/DynamicForm.tsx
  6. 93
      src/components/PageComponents/Config/Security.tsx
  7. 2
      src/components/PageComponents/Messages/ChannelChat.tsx
  8. 2
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  9. 66
      src/components/PageLayout.tsx
  10. 37
      src/components/UI/Footer.tsx
  11. 2
      src/components/UI/Tabs.tsx
  12. 136
      src/pages/Dashboard/index.tsx
  13. 106
      src/pages/Messages.tsx
  14. 136
      src/pages/Nodes.tsx

44
.github/workflows/ci.yml

@ -1,6 +1,9 @@
name: CI name: CI
on: push on:
push:
branches:
- master
permissions: permissions:
contents: write contents: write
@ -21,42 +24,3 @@ jobs:
- name: Build Package - name: Build Package
run: pnpm build run: pnpm build
- name: Package Output
run: pnpm package
- name: Upload Artifact
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "latest"
prerelease: false
files: |
./dist/build.tar
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Buildah Build
id: build-container
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./Containerfile
image: ${{github.event.repository.full_name}}
tags: latest ${{ github.sha }}
oci: true
platforms: linux/amd64, linux/arm64
- name: Push To Registry
id: push-to-registry
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-container.outputs.image }}
tags: ${{ steps.build-container.outputs.tags }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Print image url
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"

9
.github/workflows/pr.yml

@ -19,3 +19,12 @@ jobs:
- name: Build Package - name: Build Package
run: pnpm build run: pnpm build
- name: Compress build
run: pnpm package
- name: Archive compressed build
uses: actions/upload-artifact@v4
with:
name: build
path: dist/build.tar

55
.github/workflows/release.yml

@ -0,0 +1,55 @@
name: 'Release'
on:
release:
types: [released]
permissions:
contents: write
packages: write
jobs:
build-and-package:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- name: Install Dependencies
run: pnpm install
- name: Build Package
run: pnpm build
- name: Package Output
run: pnpm package
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Buildah Build
id: build-container
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./Containerfile
image: ${{github.event.repository.full_name}}
tags: latest ${{ github.sha }}
oci: true
platforms: linux/amd64, linux/arm64
- name: Push To Registry
id: push-to-registry
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-container.outputs.image }}
tags: ${{ steps.build-container.outputs.tags }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Print image url
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"

7
src/App.tsx

@ -5,6 +5,7 @@ import { DeviceSelector } from "@components/DeviceSelector.js";
import { DialogManager } from "@components/Dialog/DialogManager.js"; import { DialogManager } from "@components/Dialog/DialogManager.js";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js";
import { Toaster } from "@components/Toaster.js"; import { Toaster } from "@components/Toaster.js";
import Footer from "@components/UI/Footer.js";
import { ThemeController } from "@components/generic/ThemeController.js"; import { ThemeController } from "@components/generic/ThemeController.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDeviceStore } from "@core/stores/deviceStore.js";
@ -40,7 +41,11 @@ export const App = (): JSX.Element => {
<PageRouter /> <PageRouter />
</div> </div>
) : ( ) : (
<Dashboard /> <>
<Dashboard />
<div className="flex flex-grow" />
<Footer />
</>
)} )}
</div> </div>
</div> </div>

12
src/components/Form/DynamicForm.tsx

@ -16,13 +16,14 @@ import {
} from "react-hook-form"; } from "react-hook-form";
interface DisabledBy<T> { interface DisabledBy<T> {
fieldName: Path<T> | "always"; fieldName: Path<T>;
selector?: number; selector?: number;
invert?: boolean; invert?: boolean;
} }
export interface BaseFormBuilderProps<T> { export interface BaseFormBuilderProps<T> {
name: Path<T>; name: Path<T>;
disabled?: boolean;
disabledBy?: DisabledBy<T>[]; disabledBy?: DisabledBy<T>[];
label: string; label: string;
description?: string; description?: string;
@ -62,11 +63,14 @@ export function DynamicForm<T extends FieldValues>({
defaultValues: defaultValues, defaultValues: defaultValues,
}); });
const isDisabled = (disabledBy?: DisabledBy<T>[]): boolean => { const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,
): boolean => {
if (disabled) return true;
if (!disabledBy) return false; if (!disabledBy) return false;
return disabledBy.some((field) => { return disabledBy.some((field) => {
if (field.fieldName === "always") return true;
const value = getValues(field.fieldName); const value = getValues(field.fieldName);
if (value === "always") return true; if (value === "always") return true;
if (typeof value === "boolean") return field.invert ? value : !value; if (typeof value === "boolean") return field.invert ? value : !value;
@ -111,7 +115,7 @@ export function DynamicForm<T extends FieldValues>({
<DynamicFormField <DynamicFormField
field={field} field={field}
control={control} control={control}
disabled={isDisabled(field.disabledBy)} disabled={isDisabled(field.disabledBy, field.disabled)}
/> />
</FieldWrapper> </FieldWrapper>
))} ))}

93
src/components/PageComponents/Config/Security.tsx

@ -160,48 +160,57 @@ export const Security = (): JSX.Element => {
}, },
}, },
}, },
{ {
type: "text", type: "text",
name: "publicKey", name: "publicKey",
label: "Public Key", label: "Public Key",
description: disabled: true,
"Sent out to other nodes on the mesh to allow them to compute a shared secret key", description:
disabledBy: [{ fieldName: "always" }], "Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: { },
value: publicKey, ],
}, },
}, {
], label: "Admin Settings",
}, description: "Settings for Admin ",
{ fields: [
label: "Admin Settings", {
description: "Settings for Admin", type: "toggle",
fields: [ name: "adminChannelEnabled",
{ label: "Allow Legacy Admin",
type: "toggle", description:
name: "adminChannelEnabled", "Allow incoming device control over the insecure legacy admin channel",
label: "Allow Legacy Admin", },
description: {
"Allow incoming device control over the insecure legacy admin channel", type: "toggle",
}, name: "isManaged",
{ label: "Managed",
type: "toggle", description:
name: "isManaged", 'If true, device is considered to be "managed" by a mesh administrator via admin messages',
label: "Managed", },
description: {
'If true, device is considered to be "managed" by a mesh administrator via admin messages', type: "passwordGenerator",
}, name: "adminKey",
{ label: "Admin Key",
type: "text", description:
name: "adminKey", "The public key authorized to send admin messages to this node",
label: "Admin Key", validationText: adminKeyValidationText,
description: devicePSKBitCount: adminKeyBitCount,
"The public key authorized to send admin messages to this node", inputChange: adminKeyInputChangeEvent,
validationText: adminKeyValidationText, selectChange: adminKeySelectChangeEvent,
inputChange: adminKeyInputChangeEvent, hide: !adminKeyVisible,
disabledBy: [{ fieldName: "adminChannelEnabled" }], buttonClick: () =>
properties: { clickEvent(
value: adminKey, setAdminKey,
adminKeyBitCount,
setAdminKeyValidationText,
),
disabledBy: [{ fieldName: "adminChannelEnabled" }],
properties: {
value: adminKey,
action: {
icon: adminKeyVisible ? EyeOff : Eye,
onClick: () => setAdminKeyVisible(!adminKeyVisible),
}, },
}, },
], ],

2
src/components/PageComponents/Messages/ChannelChat.tsx

@ -68,7 +68,7 @@ export const ChannelChat = ({
)} )}
</div> </div>
</div> </div>
<div className="p-3"> <div className="pl-3 pr-3 pt-3 pb-1">
<MessageInput to={to} channel={channel} /> <MessageInput to={to} channel={channel} />
</div> </div>
</div> </div>

2
src/components/PageComponents/ModuleConfig/Telemetry.tsx

@ -87,7 +87,7 @@ export const Telemetry = (): JSX.Element => {
description: "How often to send Power data over the mesh", description: "How often to send Power data over the mesh",
}, },
{ {
type: "text", type: "toggle",
name: "powerScreenEnabled", name: "powerScreenEnabled",
label: "Power Screen Enabled", label: "Power Screen Enabled",
description: "Enable the Power Telemetry Screen", description: "Enable the Power Telemetry Screen",

66
src/components/PageLayout.tsx

@ -1,5 +1,6 @@
import { cn } from "@app/core/utils/cn.js"; import { cn } from "@app/core/utils/cn.js";
import { AlignLeftIcon, type LucideIcon } from "lucide-react"; import { AlignLeftIcon, type LucideIcon } from "lucide-react";
import Footer from "./UI/Footer";
export interface PageLayoutProps { export interface PageLayoutProps {
label: string; label: string;
@ -18,40 +19,43 @@ export const PageLayout = ({
children, children,
}: PageLayoutProps): JSX.Element => { }: PageLayoutProps): JSX.Element => {
return ( return (
<div className="relative flex h-full w-full flex-col"> <>
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4"> <div className="relative flex h-full w-full flex-col">
<button <div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
type="button" <button
className="pl-4 transition-all hover:text-accent md:hidden" type="button"
> className="pl-4 transition-all hover:text-accent md:hidden"
<AlignLeftIcon /> >
</button> <AlignLeftIcon />
<div className="flex flex-1 items-center justify-between px-4 md:px-0"> </button>
<div className="flex w-full items-center"> <div className="flex flex-1 items-center justify-between px-4 md:px-0">
<span className="w-full text-lg font-medium">{label}</span> <div className="flex w-full items-center">
<div className="flex justify-end space-x-4"> <span className="w-full text-lg font-medium">{label}</span>
{actions?.map((action, index) => ( <div className="flex justify-end space-x-4">
<button {actions?.map((action, index) => (
key={action.icon.name} <button
type="button" key={action.icon.name}
className="transition-all hover:text-accent" type="button"
onClick={action.onClick} className="transition-all hover:text-accent"
> onClick={action.onClick}
<action.icon /> >
</button> <action.icon />
))} </button>
))}
</div>
</div> </div>
</div> </div>
</div> </div>
<div
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "pl-3 pr-3 ",
)}
>
{children}
<Footer />
</div>
</div> </div>
<div </>
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "p-3",
)}
>
{children}
</div>
</div>
); );
}; };

37
src/components/UI/Footer.tsx

@ -0,0 +1,37 @@
import React from "react";
export interface FooterProps extends React.HTMLAttributes<HTMLElement> {}
const Footer = React.forwardRef<HTMLElement, FooterProps>(
({ className, ...props }, ref) => {
return (
<footer
className={`flex flex- justify-center p-2 ${className}`}
style={{
backgroundColor: "var(--backgroundPrimary)",
color: "var(--textPrimary)",
}}
>
<p>
<a
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
className="hover:underline"
style={{ color: "var(--link)" }}
>
Powered by Vercel
</a>{" "}
| Meshtastic® is a registered trademark of Meshtastic LLC. |{" "}
<a
href="https://meshtastic.org/docs/legal"
className="hover:underline"
style={{ color: "var(--link)" }}
>
Legal Information
</a>
</p>
</footer>
);
},
);
export default Footer;

2
src/components/UI/Tabs.tsx

@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 dark:bg-slate-800", "inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 mt-2 dark:bg-slate-800",
className, className,
)} )}
{...props} {...props}

136
src/pages/Dashboard/index.tsx

@ -21,79 +21,81 @@ export const Dashboard = () => {
const devices = useMemo(() => getDevices(), [getDevices]); const devices = useMemo(() => getDevices(), [getDevices]);
return ( return (
<div className="flex flex-col gap-3 p-3"> <>
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 p-3">
<div className="space-y-1"> <div className="flex items-center justify-between">
<H3>Connected Devices</H3> <div className="space-y-1">
<Subtle>Manage, connect and disconnect devices</Subtle> <H3>Connected Devices</H3>
<Subtle>Manage, connect and disconnect devices</Subtle>
</div>
</div> </div>
</div>
<Separator /> <Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700"> <div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length ? ( {devices.length ? (
<ul className="grow divide-y divide-gray-200"> <ul className="grow divide-y divide-gray-200">
{devices.map((device) => { {devices.map((device) => {
return ( return (
<li key={device.id}> <li key={device.id}>
<div className="px-4 py-4 sm:px-6"> <div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent"> <p className="truncate text-sm font-medium text-accent">
{device.nodes.get(device.hardware.myNodeNum)?.user {device.nodes.get(device.hardware.myNodeNum)?.user
?.longName ?? "UNK"} ?.longName ?? "UNK"}
</p> </p>
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50"> <div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
{device.connection?.connType === "ble" && ( {device.connection?.connType === "ble" && (
<> <>
<BluetoothIcon size={16} /> <BluetoothIcon size={16} />
BLE BLE
</> </>
)} )}
{device.connection?.connType === "serial" && ( {device.connection?.connType === "serial" && (
<> <>
<UsbIcon size={16} /> <UsbIcon size={16} />
Serial Serial
</> </>
)} )}
{device.connection?.connType === "http" && ( {device.connection?.connType === "http" && (
<> <>
<NetworkIcon size={16} /> <NetworkIcon size={16} />
Network Network
</> </>
)} )}
</div>
</div> </div>
</div> <div className="mt-2 sm:flex sm:justify-between">
<div className="mt-2 sm:flex sm:justify-between"> <div className="flex gap-2 text-sm text-gray-500">
<div className="flex gap-2 text-sm text-gray-500"> <UsersIcon
<UsersIcon size={20}
size={20} className="text-gray-400"
className="text-gray-400" aria-hidden="true"
aria-hidden="true" />
/> {device.nodes.size === 0 ? 0 : device.nodes.size - 1}
{device.nodes.size === 0 ? 0 : device.nodes.size - 1} </div>
</div> </div>
</div> </div>
</div> </li>
</li> );
); })}
})} </ul>
</ul> ) : (
) : ( <div className="m-auto flex flex-col gap-3 text-center">
<div className="m-auto flex flex-col gap-3 text-center"> <ListPlusIcon size={48} className="mx-auto text-textSecondary" />
<ListPlusIcon size={48} className="mx-auto text-textSecondary" /> <H3>No Devices</H3>
<H3>No Devices</H3> <Subtle>Connect atleast one device to get started</Subtle>
<Subtle>Connect atleast one device to get started</Subtle> <Button
<Button className="gap-2"
className="gap-2" onClick={() => setConnectDialogOpen(true)}
onClick={() => setConnectDialogOpen(true)} >
> <PlusIcon size={16} />
<PlusIcon size={16} /> New Connection
New Connection </Button>
</Button> </div>
</div> )}
)} </div>
</div> </div>
</div> </>
); );
}; };

106
src/pages/Messages.tsx

@ -67,60 +67,62 @@ export const MessagesPage = (): JSX.Element => {
))} ))}
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>
<PageLayout <div className="flex flex-col flex-grow">
label={`Messages: ${ <PageLayout
chatType === "broadcast" && currentChannel label={`Messages: ${
? getChannelName(currentChannel) chatType === "broadcast" && currentChannel
: chatType === "direct" && nodes.get(activeChat) ? getChannelName(currentChannel)
? nodes.get(activeChat)?.user?.longName ?? "Unknown" : chatType === "direct" && nodes.get(activeChat)
: "Loading..." ? nodes.get(activeChat)?.user?.longName ?? "Unknown"
}`} : "Loading..."
actions={ }`}
chatType === "direct" actions={
? [ chatType === "direct"
{ ? [
icon: WaypointsIcon, {
async onClick() { icon: WaypointsIcon,
const targetNode = nodes.get(activeChat)?.num; async onClick() {
if (targetNode === undefined) return; const targetNode = nodes.get(activeChat)?.num;
toast({ if (targetNode === undefined) return;
title: "Sending Traceroute, please wait...",
});
await connection?.traceRoute(targetNode).then(() =>
toast({ toast({
title: "Traceroute sent.", title: "Sending Traceroute, please wait...",
}), });
); await connection?.traceRoute(targetNode).then(() =>
toast({
title: "Traceroute sent.",
}),
);
},
}, },
}, ]
] : []
: [] }
} >
> {allChannels.map(
{allChannels.map( (channel) =>
(channel) => activeChat === channel.index && (
activeChat === channel.index && ( <ChannelChat
<ChannelChat key={channel.index}
key={channel.index} to="broadcast"
to="broadcast" messages={messages.broadcast.get(channel.index)}
messages={messages.broadcast.get(channel.index)} channel={channel.index}
channel={channel.index} />
/> ),
), )}
)} {filteredNodes.map(
{filteredNodes.map( (node) =>
(node) => activeChat === node.num && (
activeChat === node.num && ( <ChannelChat
<ChannelChat key={node.num}
key={node.num} to={activeChat}
to={activeChat} messages={messages.direct.get(node.num)}
messages={messages.direct.get(node.num)} channel={Types.ChannelNumber.Primary}
channel={Types.ChannelNumber.Primary} traceroutes={traceroutes.get(node.num)}
traceroutes={traceroutes.get(node.num)} />
/> ),
), )}
)} </PageLayout>
</PageLayout> </div>
</> </>
); );
}; };

136
src/pages/Nodes.tsx

@ -1,3 +1,4 @@
import Footer from "@app/components/UI/Footer";
import { useAppStore } from "@app/core/stores/appStore"; import { useAppStore } from "@app/core/stores/appStore";
import { Sidebar } from "@components/Sidebar.js"; import { Sidebar } from "@components/Sidebar.js";
import { Button } from "@components/UI/Button.js"; import { Button } from "@components/UI/Button.js";
@ -27,73 +28,76 @@ export const NodesPage = (): JSX.Element => {
return ( return (
<> <>
<Sidebar /> <Sidebar />
<div className="w-full overflow-y-auto"> <div className="flex flex-col w-full">
<Table <div className="overflow-y-auto h-full">
headings={[ <Table
{ title: "", type: "blank", sortable: false }, headings={[
{ title: "Name", type: "normal", sortable: true }, { title: "", type: "blank", sortable: false },
{ title: "Model", type: "normal", sortable: true }, { title: "Name", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true }, { title: "Model", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true }, { title: "MAC Address", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true }, { title: "Last Heard", type: "normal", sortable: true },
{ title: "Connection", type: "normal", sortable: true }, { title: "SNR", type: "normal", sortable: true },
{ title: "Remove", type: "normal", sortable: false }, { title: "Connection", type: "normal", sortable: true },
]} { title: "Remove", type: "normal", sortable: false },
rows={filteredNodes.map((node) => [ ]}
<Hashicon key="icon" size={24} value={node.num.toString()} />, rows={filteredNodes.map((node) => [
<h1 key="header"> <Hashicon key="icon" size={24} value={node.num.toString()} />,
{node.user?.longName ?? <h1 key="header">
(node.user?.macaddr {node.user?.longName ??
? `Meshtastic ${base16 (node.user?.macaddr
.stringify(node.user?.macaddr.subarray(4, 6) ?? []) ? `Meshtastic ${base16
.toLowerCase()}` .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
: `UNK: ${node.num}`)} .toLowerCase()}`
</h1>, : `UNK: ${node.num}`)}
</h1>,
<Mono key="model"> <Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>, </Mono>,
<Mono key="addr"> <Mono key="addr">
{base16 {base16
.stringify(node.user?.macaddr ?? []) .stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g) .match(/.{1,2}/g)
?.join(":") ?? "UNK"} ?.join(":") ?? "UNK"}
</Mono>, </Mono>,
<Fragment key="lastHeard"> <Fragment key="lastHeard">
{node.lastHeard === 0 ? ( {node.lastHeard === 0 ? (
<p>Never</p> <p>Never</p>
) : ( ) : (
<TimeAgo timestamp={node.lastHeard * 1000} /> <TimeAgo timestamp={node.lastHeard * 1000} />
)} )}
</Fragment>, </Fragment>,
<Mono key="snr"> <Mono key="snr">
{node.snr}db/ {node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/ {Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw {(node.snr + 10) * 5}raw
</Mono>, </Mono>,
<Mono key="hops"> <Mono key="hops">
{node.lastHeard !== 0 {node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0 ? node.viaMqtt === false && node.hopsAway === 0
? "Direct" ? "Direct"
: `${node.hopsAway.toString()} ${ : `${node.hopsAway.toString()} ${
node.hopsAway > 1 ? "hops" : "hop" node.hopsAway > 1 ? "hops" : "hop"
} away` } away`
: "-"} : "-"}
{node.viaMqtt === true ? ", via MQTT" : ""} {node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>, </Mono>,
<Button <Button
key="remove" key="remove"
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
setNodeNumToBeRemoved(node.num); setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true); setDialogOpen("nodeRemoval", true);
}} }}
> >
<TrashIcon /> <TrashIcon />
Remove Remove
</Button>, </Button>,
])} ])}
/> />
</div>
<Footer />
</div> </div>
</> </>
); );

Loading…
Cancel
Save