refactor: switch to using Bun (#718)

This commit is contained in:
Dan Ditomaso
2025-07-16 22:30:10 -04:00
committed by GitHub
parent acfd7ebd2d
commit 704d06cfe7
401 changed files with 35294 additions and 39107 deletions
+48 -45
View File
@@ -1,15 +1,15 @@
import { Channel } from "@components/PageComponents/Channel.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { Channel } from "@components/PageComponents/Channel.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Types } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/core";
import { Types } from "@meshtastic/core";
import i18next from "i18next";
import { QrCodeIcon, UploadIcon } from "lucide-react";
import { useState } from "react";
@@ -19,8 +19,11 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) => {
return channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
? i18next.t("page.broadcastLabel")
: i18next.t("page.channelIndex", { ns: "channels", index: channel.index });
? i18next.t("page.broadcastLabel")
: i18next.t("page.channelIndex", {
ns: "channels",
index: channel.index,
});
};
const ChannelsPage = () => {
@@ -34,50 +37,50 @@ const ChannelsPage = () => {
const allChannels = Array.from(channels.values());
return (
<>
<PageLayout
contentClassName="overflow-auto"
leftBar={<Sidebar />}
label={currentChannel
<PageLayout
contentClassName="overflow-auto"
leftBar={<Sidebar />}
label={
currentChannel
? getChannelName(currentChannel)
: t("loading", { ns: "common" })}
actions={[
{
key: "import",
icon: UploadIcon,
onClick() {
setDialogOpen("import", true);
},
: t("loading", { ns: "common" })
}
actions={[
{
key: "import",
icon: UploadIcon,
onClick() {
setDialogOpen("import", true);
},
{
key: "qr",
icon: QrCodeIcon,
onClick() {
setDialogOpen("QR", true);
},
},
{
key: "qr",
icon: QrCodeIcon,
onClick() {
setDialogOpen("QR", true);
},
]}
>
<Tabs defaultValue="0">
<TabsList className="dark:bg-slate-800">
{allChannels.map((channel) => (
<TabsTrigger
key={channel.index}
value={channel.index.toString()}
className="dark:text-white"
>
{getChannelName(channel)}
</TabsTrigger>
))}
</TabsList>
},
]}
>
<Tabs defaultValue="0">
<TabsList className="dark:bg-slate-800">
{allChannels.map((channel) => (
<TabsContent key={channel.index} value={channel.index.toString()}>
<Channel key={channel.index} channel={channel} />
</TabsContent>
<TabsTrigger
key={channel.index}
value={channel.index.toString()}
className="dark:text-white"
>
{getChannelName(channel)}
</TabsTrigger>
))}
</Tabs>
</PageLayout>
</>
</TabsList>
{allChannels.map((channel) => (
<TabsContent key={channel.index} value={channel.index.toString()}>
<Channel key={channel.index} channel={channel} />
</TabsContent>
))}
</Tabs>
</PageLayout>
);
};
export default ChannelsPage;
+9 -14
View File
@@ -6,25 +6,22 @@ import { Network } from "@components/PageComponents/Config/Network/index.tsx";
import { Position } from "@components/PageComponents/Config/Position.tsx";
import { Power } from "@components/PageComponents/Config/Power.tsx";
import { Security } from "@components/PageComponents/Config/Security/Security.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { useTranslation } from "react-i18next";
import { useDevice, type ValidConfigType } from "@core/stores/deviceStore.ts";
import { useMemo } from "react";
import { type ComponentType, Suspense } from "react";
import { type ComponentType, Suspense, useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
import { useTranslation } from "react-i18next";
interface ConfigProps {
// We can get rid of this exception if we import every config schema and pass the union type
// deno-lint-ignore no-explicit-any
onFormInit: (methods: UseFormReturn<any>) => void;
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
}
type TabItem = {
case: ValidConfigType;
label: string;
@@ -96,10 +93,8 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
{tab.label}
{flags.get(tab.case) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25">
</span>
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
</span>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
</span>
)}
</TabsTrigger>
@@ -108,9 +103,9 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<ConfigSuspender configCase={tab.case}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<tab.element onFormInit={onFormInit} />
</ConfigSuspender>
</Suspense>
</Suspense>
</TabsContent>
))}
+10 -17
View File
@@ -1,37 +1,34 @@
import { AmbientLighting } from "@components/PageComponents/ModuleConfig/AmbientLighting.tsx";
import { DetectionSensor } from "@components/PageComponents/ModuleConfig/DetectionSensor.tsx";
import { NeighborInfo } from "@components/PageComponents/ModuleConfig/NeighborInfo.tsx";
import { Audio } from "@components/PageComponents/ModuleConfig/Audio.tsx";
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.tsx";
import { DetectionSensor } from "@components/PageComponents/ModuleConfig/DetectionSensor.tsx";
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.tsx";
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.tsx";
import { NeighborInfo } from "@components/PageComponents/ModuleConfig/NeighborInfo.tsx";
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.tsx";
import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.tsx";
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.tsx";
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.tsx";
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { useTranslation } from "react-i18next";
import {
useDevice,
type ValidModuleConfigType,
} from "@core/stores/deviceStore.ts";
import { useMemo } from "react";
import { type ComponentType, Suspense } from "react";
import { type ComponentType, Suspense, useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
import { useTranslation } from "react-i18next";
interface ConfigProps {
// We can get rid of this exception if we import every config schema and pass the union type
// deno-lint-ignore no-explicit-any
onFormInit: (methods: UseFormReturn<any>) => void;
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
}
type TabItem = {
case: ValidModuleConfigType;
label: string;
@@ -119,10 +116,8 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
{tab.label}
{flags.get(tab.case) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25">
</span>
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
</span>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
</span>
)}
</TabsTrigger>
@@ -131,9 +126,7 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<ConfigSuspender moduleConfigCase={tab.case}>
<tab.element onFormInit={onFormInit} />
</ConfigSuspender>
<tab.element onFormInit={onFormInit} />
</Suspense>
</TabsContent>
))}
+114 -90
View File
@@ -1,11 +1,11 @@
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import {
@@ -15,10 +15,9 @@ import {
SaveOff,
SettingsIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import type { FieldValues, UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { cn } from "@core/utils/cn.ts";
import type { UseFormReturn } from "react-hook-form";
const ConfigPage = () => {
const {
@@ -40,11 +39,14 @@ const ConfigPage = () => {
const { toast } = useToast();
const { t } = useTranslation("deviceConfig");
const onFormInit = (methods: UseFormReturn) => {
setFormMethods(methods);
};
const onFormInit = useCallback(
<T extends FieldValues>(methods: UseFormReturn<T>) => {
setFormMethods(methods as UseFormReturn);
},
[],
);
const handleSave = async () => {
const handleSave = useCallback(async () => {
if (hasErrors()) {
return toast({
title: t("toast.validationError.title"),
@@ -64,7 +66,7 @@ const ConfigPage = () => {
case: newConfig.payloadVariant.case,
}),
});
})
}),
),
);
@@ -76,21 +78,24 @@ const ConfigPage = () => {
description: t("toast.saveSuccess.description", {
case: newModuleConfig.payloadVariant.case,
}),
})
)
}),
),
),
);
await connection?.commitEditSettings().then(() => {
if (formMethods) {
formMethods.reset({}, {
keepValues: true,
});
formMethods.reset(
{},
{
keepValues: true,
},
);
}
workingConfig.map((newConfig) => setConfig(newConfig));
workingModuleConfig.map((newModuleConfig) =>
setModuleConfig(newModuleConfig)
setModuleConfig(newModuleConfig),
);
removeWorkingConfig();
@@ -104,24 +109,33 @@ const ConfigPage = () => {
} finally {
setIsSaving(false);
}
};
}, [
hasErrors,
toast,
t,
workingConfig,
connection,
workingModuleConfig,
formMethods,
setConfig,
setModuleConfig,
removeWorkingConfig,
removeWorkingModuleConfig,
]);
const handleReset = () => {
const handleReset = useCallback(() => {
if (formMethods) {
formMethods.reset();
}
removeWorkingConfig();
removeWorkingModuleConfig();
};
}, [formMethods, removeWorkingConfig, removeWorkingModuleConfig]);
const leftSidebar = useMemo(
() => (
<Sidebar>
<SidebarSection
label={t("sidebar.label")}
className="py-2 px-0"
>
<SidebarSection label={t("sidebar.label")} className="py-2 px-0">
<SidebarButton
label={t("navigation.radioConfig")}
active={activeConfigSection === "device"}
@@ -141,7 +155,7 @@ const ConfigPage = () => {
</SidebarSection>
</Sidebar>
),
[activeConfigSection, workingConfig, workingModuleConfig],
[activeConfigSection, workingConfig, workingModuleConfig, t],
);
const buttonOpacity = useMemo(() => {
@@ -151,8 +165,10 @@ const ConfigPage = () => {
const hasWorkingConfig = workingConfig.length > 0;
const hasWorkingModuleConfig = workingModuleConfig.length > 0;
const shouldShowButton = (isFormDirty && hasDirtyFields) ||
hasWorkingConfig || hasWorkingModuleConfig;
const shouldShowButton =
(isFormDirty && hasDirtyFields) ||
hasWorkingConfig ||
hasWorkingModuleConfig;
return shouldShowButton ? "opacity-100" : "opacity-0";
}, [
@@ -166,71 +182,79 @@ const ConfigPage = () => {
return Object.keys(formMethods?.formState.errors ?? {}).length === 0;
}, [formMethods?.formState.errors]);
const actions = useMemo(() => [
{
key: "unsavedChanges",
label: t("common:formValidation.unsavedChanges"),
onClick: () => {},
className: cn([
"bg-blue-500 text-slate-900 hover:bg-initial",
"transition-colors duration-200",
buttonOpacity,
"transition-opacity",
]),
},
{
key: "reset",
icon: RefreshCwIcon,
label: t("common:button.reset"),
onClick: handleReset,
className: cn([
buttonOpacity,
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
"hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer",
]),
},
{
key: "save",
icon: !isValid ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled: isSaving ||
!isValid ||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
iconClasses: !isValid
? "text-red-400 cursor-not-allowed"
: "cursor-pointer",
className: cn([
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
"hover:dark:bg-slate-300 hover:dark:text-black",
"disabled:hover:cursor-not-allowed cursor-pointer",
]),
onClick: handleSave,
label: t("common:button.save"),
},
], [
activeConfigSection,
isSaving,
isValid,
buttonOpacity,
workingConfig,
workingModuleConfig,
]);
const actions = useMemo(
() => [
{
key: "unsavedChanges",
label: t("common:formValidation.unsavedChanges"),
onClick: () => {},
className: cn([
"bg-blue-500 text-slate-900 hover:bg-initial",
"transition-colors duration-200",
buttonOpacity,
"transition-opacity",
]),
},
{
key: "reset",
icon: RefreshCwIcon,
label: t("common:button.reset"),
onClick: handleReset,
className: cn([
buttonOpacity,
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
"hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer",
]),
},
{
key: "save",
icon: !isValid ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled:
isSaving ||
!isValid ||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
iconClasses: !isValid
? "text-red-400 cursor-not-allowed"
: "cursor-pointer",
className: cn([
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
"hover:dark:bg-slate-300 hover:dark:text-black",
"disabled:hover:cursor-not-allowed cursor-pointer",
]),
onClick: handleSave,
label: t("common:button.save"),
},
],
[
isSaving,
isValid,
buttonOpacity,
workingConfig,
workingModuleConfig,
handleReset,
handleSave,
t,
],
);
return (
<>
<PageLayout
contentClassName="overflow-auto"
leftBar={leftSidebar}
label={activeConfigSection === "device"
<PageLayout
contentClassName="overflow-auto"
leftBar={leftSidebar}
label={
activeConfigSection === "device"
? t("navigation.radioConfig")
: t("navigation.moduleConfig")}
actions={actions}
>
{activeConfigSection === "device"
? <DeviceConfig onFormInit={onFormInit} />
: <ModuleConfig onFormInit={onFormInit} />}
</PageLayout>
</>
: t("navigation.moduleConfig")
}
actions={actions}
>
{activeConfigSection === "device" ? (
<DeviceConfig onFormInit={onFormInit} />
) : (
<ModuleConfig onFormInit={onFormInit} />
)}
</PageLayout>
);
};
+66 -84
View File
@@ -1,13 +1,13 @@
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
export const Dashboard = () => {
const { t } = useTranslation("dashboard");
@@ -17,87 +17,69 @@ export const Dashboard = () => {
const devices = useMemo(() => getDevices(), [getDevices]);
return (
<>
<div className="flex flex-col gap-3 p-3 px-8">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Heading as="h3">
{t("dashboard.title")}
</Heading>
<Subtle>
{t("dashboard.description")}
</Subtle>
</div>
<LanguageSwitcher />
</div>
<Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length
? (
<ul className="grow divide-y divide-slate-200">
{devices.map((device) => {
return (
<li key={device.id}>
<button
type="button"
className="w-full px-4 py-4 sm:px-6"
onClick={() => {
setSelectedDevice(device.id);
}}
>
<div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent">
{device.getNode(device.hardware.myNodeNum)?.user
?.longName ??
t("unknown.shortName")}
</p>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-slate-500">
<UsersIcon
size={20}
className="text-slate-400"
aria-hidden="true"
/>
{device.getNodesLength() === 0
? 0
: device.getNodesLength() - 1}
</div>
</div>
</div>
</button>
</li>
);
})}
</ul>
)
: (
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon
size={48}
className="mx-auto text-text-secondary"
/>
<Heading as="h3">
{t("dashboard.noDevicesTitle")}
</Heading>
<Subtle>
{t("dashboard.noDevicesDescription")}
</Subtle>
<Button
className="gap-2"
variant="default"
onClick={() => setConnectDialogOpen(true)}
>
<PlusIcon size={16} />
{t(
"dashboard.button_newConnection",
)}
</Button>
</div>
)}
<div className="flex flex-col gap-3 p-3 px-8">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Heading as="h3">{t("dashboard.title")}</Heading>
<Subtle>{t("dashboard.description")}</Subtle>
</div>
<LanguageSwitcher />
</div>
</>
<Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length ? (
<ul className="grow divide-y divide-slate-200">
{devices.map((device) => {
return (
<li key={device.id}>
<button
type="button"
className="w-full px-4 py-4 sm:px-6"
onClick={() => {
setSelectedDevice(device.id);
}}
>
<div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent">
{device.getNode(device.hardware.myNodeNum)?.user
?.longName ?? t("unknown.shortName")}
</p>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-slate-500">
<UsersIcon
size={20}
className="text-slate-400"
aria-hidden="true"
/>
{device.getNodesLength() === 0
? 0
: device.getNodesLength() - 1}
</div>
</div>
</div>
</button>
</li>
);
})}
</ul>
) : (
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon size={48} className="mx-auto text-text-secondary" />
<Heading as="h3">{t("dashboard.noDevicesTitle")}</Heading>
<Subtle>{t("dashboard.noDevicesDescription")}</Subtle>
<Button
className="gap-2"
variant="default"
onClick={() => setConnectDialogOpen(true)}
>
<PlusIcon size={16} />
{t("dashboard.button_newConnection")}
</Button>
</div>
)}
</div>
</div>
);
};
+61 -62
View File
@@ -1,20 +1,20 @@
import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx";
import { Avatar } from "../../components/UI/Avatar.tsx";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import {
type FilterState,
useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts";
import { BaseMap } from "@components/Map.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
import type { Protobuf } from "@meshtastic/core";
import { bbox, lineString } from "@turf/turf";
import { FunnelIcon, MapPinIcon } from "lucide-react";
import { useCallback, useDeferredValue, useMemo, useState } from "react";
import { Marker, Popup, useMap } from "react-map-gl/maplibre";
import { Map } from "@components/Map.tsx";
import {
type FilterState,
useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import { cn } from "@core/utils/cn.ts";
import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx";
import { Avatar } from "../../components/UI/Avatar.tsx";
type NodePosition = {
latitude: number;
@@ -35,27 +35,25 @@ const MapPage = () => {
const { default: map } = useMap();
const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | null
>(null);
const [selectedNode, setSelectedNode] =
useState<Protobuf.Mesh.NodeInfo | null>(null);
const validNodes = useMemo(
() =>
getNodes(
(node): node is Protobuf.Mesh.NodeInfo =>
Boolean(node.position?.latitudeI),
getNodes((node): node is Protobuf.Mesh.NodeInfo =>
Boolean(node.position?.latitudeI),
),
[getNodes],
);
const [filterState, setFilterState] = useState<FilterState>(() =>
defaultFilterValues
const [filterState, setFilterState] = useState<FilterState>(
() => defaultFilterValues,
);
const deferredFilterState = useDeferredValue(filterState);
const filteredNodes = useMemo(
() => validNodes.filter((node) => nodeFilter(node, deferredFilterState)),
[validNodes, deferredFilterState],
[validNodes, deferredFilterState, nodeFilter],
);
const handleMarkerClick = useCallback(
@@ -77,7 +75,9 @@ const MapPage = () => {
// Get the bounds of the map based on the nodes furtherest away from center
const getMapBounds = useCallback(() => {
if (!map || validNodes.length === 0) return;
if (!map || validNodes.length === 0) {
return;
}
if (validNodes.length === 1) {
map.easeTo({
@@ -131,27 +131,27 @@ const MapPage = () => {
</Marker>
);
}),
[filteredNodes, handleMarkerClick],
[filteredNodes, handleMarkerClick, hasNodeError],
);
return (
<>
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
<Map onLoad={getMapBounds}>
{waypoints.map((wp) => (
<Marker
key={wp.id}
longitude={(wp.longitudeI ?? 0) / 1e7}
latitude={(wp.latitudeI ?? 0) / 1e7}
anchor="bottom"
>
<div>
<MapPinIcon size={16} />
</div>
</Marker>
))}
{markers}
{selectedNode && (() => {
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
<BaseMap onLoad={getMapBounds}>
{waypoints.map((wp) => (
<Marker
key={wp.id}
longitude={(wp.longitudeI ?? 0) / 1e7}
latitude={(wp.latitudeI ?? 0) / 1e7}
anchor="bottom"
>
<div>
<MapPinIcon size={16} />
</div>
</Marker>
))}
{markers}
{selectedNode &&
(() => {
const position = convertToLatLng(selectedNode.position);
return (
<Popup
@@ -166,32 +166,31 @@ const MapPage = () => {
</Popup>
);
})()}
</Map>
</BaseMap>
<FilterControl
filterState={filterState}
defaultFilterValues={defaultFilterValues}
setFilterState={setFilterState}
isDirty={isFilterDirty(filterState)}
parameters={{
popoverContentProps: {
side: "bottom",
align: "end",
sideOffset: 12,
},
popoverTriggerClassName: cn(
"fixed top-45.5 right-2.5 w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-50 dark:hover:bg-stone-200 dark:active:bg-stone-300",
isFilterDirty(filterState)
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
: "",
),
triggerIcon: <FunnelIcon className="w-5" />,
showTextSearch: true,
}}
/>
</PageLayout>
</>
<FilterControl
filterState={filterState}
defaultFilterValues={defaultFilterValues}
setFilterState={setFilterState}
isDirty={isFilterDirty(filterState)}
parameters={{
popoverContentProps: {
side: "bottom",
align: "end",
sideOffset: 12,
},
popoverTriggerClassName: cn(
"fixed top-45.5 right-2.5 w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-50 dark:hover:bg-stone-200 dark:active:bg-stone-300",
isFilterDirty(filterState)
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
: "",
),
triggerIcon: <FunnelIcon className="w-5" />,
showTextSearch: true,
}}
/>
</PageLayout>
);
};
+179 -167
View File
@@ -1,13 +1,25 @@
import { messagesWithParamsRoute } from "@app/routes.tsx";
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { Input } from "@components/UI/Input.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import {
MessageState,
MessageType,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { cn } from "@core/utils/cn.ts";
import { randId } from "@core/utils/randId.ts";
import { Protobuf, Types } from "@meshtastic/core";
import { getChannelName } from "@pages/Channels.tsx";
import { useNavigate, useParams } from "@tanstack/react-router";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import {
useCallback,
@@ -16,19 +28,7 @@ import {
useMemo,
useState,
} from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts";
import {
MessageState,
MessageType,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { Input } from "@components/UI/Input.tsx";
import { randId } from "@core/utils/randId.ts";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { messagesWithParamsRoute } from "@app/routes.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
@@ -51,11 +51,7 @@ export const MessagesPage = () => {
resetUnread,
connection,
} = useDevice();
const {
getMyNodeNum,
getMessages,
setMessageState,
} = useMessageStore();
const { getMyNodeNum, getMessages, setMessageState } = useMessageStore();
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
@@ -66,14 +62,16 @@ export const MessagesPage = () => {
const { t } = useTranslation(["messages", "channels", "ui"]);
const deferredSearch = useDeferredValue(searchTerm);
const navigateToChat = useCallback((type: MessageType, id: string) => {
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
navigate({ to: `/messages/${typeParam}/${id}` });
}, [navigate]);
const navigateToChat = useCallback(
(type: MessageType, id: string) => {
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
navigate({ to: `/messages/${typeParam}/${id}` });
},
[navigate],
);
const chatType = type === "direct"
? MessageType.Direct
: MessageType.Broadcast;
const chatType =
type === "direct" ? MessageType.Direct : MessageType.Broadcast;
const numericChatId = Number(chatId);
const allChannels = Array.from(channels.values());
@@ -94,87 +92,96 @@ export const MessagesPage = () => {
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
const filteredNodes = (): NodeInfoWithUnread[] => {
const filteredNodes = useCallback((): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
return getNodes((node) => {
return getNodes((node: Protobuf.Mesh.NodeInfo) => {
const longName = node.user?.longName?.toLowerCase() ?? "";
const shortName = node.user?.shortName?.toLowerCase() ?? "";
return longName.includes(lowerCaseSearchTerm) ||
shortName.includes(lowerCaseSearchTerm);
return (
longName.includes(lowerCaseSearchTerm) ||
shortName.includes(lowerCaseSearchTerm)
);
})
.map((node) => ({
.map((node: Protobuf.Mesh.NodeInfo) => ({
...node,
unreadCount: getUnreadCount(node.num) ?? 0,
}))
.sort((a, b) => {
.sort((a: NodeInfoWithUnread, b: NodeInfoWithUnread) => {
const diff = b.unreadCount - a.unreadCount;
if (diff !== 0) return diff;
if (diff !== 0) {
return diff;
}
return Number(b.isFavorite) - Number(a.isFavorite);
});
};
}, [deferredSearch, getNodes, getUnreadCount]);
const sendText = useCallback(async (message: string) => {
const toValue = isDirect ? numericChatId : MessageType.Broadcast;
const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId;
const sendText = useCallback(
async (message: string) => {
const toValue = isDirect ? numericChatId : MessageType.Broadcast;
const channelValue = isDirect
? Types.ChannelNumber.Primary
: numericChatId;
let messageId: number | undefined;
let messageId: number | undefined;
try {
messageId = await connection?.sendText(
message,
toValue,
true,
channelValue,
);
if (messageId !== undefined) {
try {
messageId = await connection?.sendText(
message,
toValue,
true,
channelValue,
);
if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) {
setMessageState({
type: MessageType.Broadcast,
channelId: channelValue,
messageId,
newState: MessageState.Ack,
});
} else {
setMessageState({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeB: numericChatId,
messageId,
newState: MessageState.Ack,
});
}
} else {
console.warn("sendText completed but messageId is undefined");
}
} catch (e: unknown) {
console.error("Failed to send message:", e);
const failedId = messageId ?? randId();
if (chatType === MessageType.Broadcast) {
setMessageState({
type: MessageType.Broadcast,
channelId: channelValue,
messageId,
newState: MessageState.Ack,
messageId: failedId,
newState: MessageState.Failed,
});
} else {
setMessageState({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeB: numericChatId,
messageId,
newState: MessageState.Ack,
messageId: failedId,
newState: MessageState.Failed,
});
}
} else {
console.warn("sendText completed but messageId is undefined");
}
} catch (e: unknown) {
console.error("Failed to send message:", e);
const failedId = messageId ?? randId();
if (chatType === MessageType.Broadcast) {
setMessageState({
type: MessageType.Broadcast,
channelId: channelValue,
messageId: failedId,
newState: MessageState.Failed,
});
} else {
setMessageState({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeB: numericChatId,
messageId: failedId,
newState: MessageState.Failed,
});
}
}
}, [
numericChatId,
chatId,
chatType,
connection,
getMyNodeNum,
setMessageState,
]);
},
[
numericChatId,
chatType,
connection,
getMyNodeNum,
setMessageState,
isDirect,
],
);
const renderChatContent = () => {
switch (chatType) {
@@ -202,48 +209,52 @@ export const MessagesPage = () => {
}
};
const leftSidebar = useMemo(() => (
<Sidebar>
<SidebarSection
label={t("navigation.channels")}
className="py-2 px-0"
>
{filteredChannels?.map((channel) => (
<SidebarButton
key={channel.index}
count={getUnreadCount(channel.index)}
label={channel.settings?.name ||
(channel.index === 0
? t("page.broadcastLabel", { ns: "channels" })
: t("page.channelLabel", {
index: channel.index,
ns: "channels",
}))}
active={numericChatId === channel.index &&
chatType === MessageType.Broadcast}
onClick={() => {
navigateToChat(MessageType.Broadcast, channel.index.toString());
resetUnread(channel.index);
}}
>
<HashIcon
size={16}
className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")}
/>
</SidebarButton>
))}
</SidebarSection>
</Sidebar>
), [
filteredChannels,
numericChatId,
chatType,
isCollapsed,
getUnreadCount,
navigateToChat,
resetUnread,
t,
]);
const leftSidebar = useMemo(
() => (
<Sidebar>
<SidebarSection label={t("navigation.channels")} className="py-2 px-0">
{filteredChannels?.map((channel) => (
<SidebarButton
key={channel.index}
count={getUnreadCount(channel.index)}
label={
channel.settings?.name ||
(channel.index === 0
? t("page.broadcastLabel", { ns: "channels" })
: t("page.channelLabel", {
index: channel.index,
ns: "channels",
}))
}
active={
numericChatId === channel.index &&
chatType === MessageType.Broadcast
}
onClick={() => {
navigateToChat(MessageType.Broadcast, channel.index.toString());
resetUnread(channel.index);
}}
>
<HashIcon
size={16}
className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")}
/>
</SidebarButton>
))}
</SidebarSection>
</Sidebar>
),
[
filteredChannels,
numericChatId,
chatType,
isCollapsed,
getUnreadCount,
navigateToChat,
resetUnread,
t,
],
);
const rightSidebar = useMemo(
() => (
@@ -251,9 +262,10 @@ export const MessagesPage = () => {
label=""
className="px-0 flex flex-col h-full overflow-y-auto"
>
<label className="p-2 block">
<label className="p-2 block" htmlFor="nodeSearch">
<Input
type="text"
name="nodeSearch"
placeholder={t("search.nodes")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
@@ -269,19 +281,18 @@ export const MessagesPage = () => {
<SidebarButton
key={node.num}
preventCollapse
label={node.user?.longName ??
t("unknown.shortName")}
label={node.user?.longName ?? t("unknown.shortName")}
count={node.unreadCount > 0 ? node.unreadCount : undefined}
active={numericChatId === node.num &&
chatType === MessageType.Direct}
active={
numericChatId === node.num && chatType === MessageType.Direct
}
onClick={() => {
navigateToChat(MessageType.Direct, node.num.toString());
resetUnread(node.num);
}}
>
<Avatar
text={node.user?.shortName ??
t("unknown.shortName")}
text={node.user?.shortName ?? t("unknown.shortName")}
className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)}
showFavorite={node.isFavorite}
@@ -306,55 +317,56 @@ export const MessagesPage = () => {
return (
<PageLayout
label={`${
t("page.title", {
interpolation: { escapeValue: false },
chatName: isBroadcast && currentChannel
label={`${t("page.title", {
interpolation: { escapeValue: false },
chatName:
isBroadcast && currentChannel
? getChannelName(currentChannel)
: isDirect && otherNode
? (otherNode.user?.longName ?? t("unknown.longName"))
: t("emptyState.title"),
})
}
? (otherNode.user?.longName ?? t("unknown.longName"))
: t("emptyState.title"),
})}
`}
rightBar={rightSidebar}
leftBar={leftSidebar}
actions={isDirect && otherNode
? [
{
key: "encryption",
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon,
iconClasses: otherNode.user?.publicKey?.length
? "text-green-600"
: "text-yellow-300",
onClick() {
toast({
title: otherNode.user?.publicKey?.length
? t("toast.messages.pkiEncryption.title")
: t("toast.messages.pskEncryption.title"),
});
},
},
]
: []}
actions={
isDirect && otherNode
? [
{
key: "encryption",
icon: otherNode.user?.publicKey?.length
? LockIcon
: LockOpenIcon,
iconClasses: otherNode.user?.publicKey?.length
? "text-green-600"
: "text-yellow-300",
onClick() {
toast({
title: otherNode.user?.publicKey?.length
? t("toast.messages.pkiEncryption.title")
: t("toast.messages.pskEncryption.title"),
});
},
},
]
: []
}
>
<div className="flex flex-1 flex-col overflow-hidden">
{renderChatContent()}
<div className="flex-none dark:bg-slate-900 p-2">
{(isBroadcast || isDirect)
? (
<MessageInput
to={isDirect ? numericChatId : MessageType.Broadcast}
onSend={sendText}
maxBytes={200}
/>
)
: (
<div className="p-4 text-center text-slate-400 italic">
{t("sendMessage.sendButton", { ns: "messages" })}
</div>
)}
{isBroadcast || isDirect ? (
<MessageInput
to={isDirect ? numericChatId : MessageType.Broadcast}
onSend={sendText}
maxBytes={200}
/>
) : (
<div className="p-4 text-center text-slate-400 italic">
{t("sendMessage.sendButton", { ns: "messages" })}
</div>
)}
</div>
</div>
</PageLayout>
+108 -113
View File
@@ -1,7 +1,10 @@
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import {
type FilterState,
useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts";
import { Mono } from "@components/generic/Mono.tsx";
import {
type DataRow,
@@ -9,8 +12,13 @@ import {
Table,
} from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Input } from "@components/UI/Input.tsx";
import useLang from "@core/hooks/useLang.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, type Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react";
@@ -22,16 +30,8 @@ import {
useMemo,
useState,
} from "react";
import { base16 } from "rfc4648";
import { Input } from "@components/UI/Input.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import {
type FilterState,
useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import { useTranslation } from "react-i18next";
import useLang from "@core/hooks/useLang.ts";
import { base16 } from "rfc4648";
export interface DeleteNoteDialogProps {
open: boolean;
@@ -53,8 +53,8 @@ const NodesPage = (): JSX.Element => {
Types.PacketMetadata<Protobuf.Mesh.Position> | undefined
>();
const [filterState, setFilterState] = useState<FilterState>(() =>
defaultFilterValues
const [filterState, setFilterState] = useState<FilterState>(
() => defaultFilterValues,
);
const deferredFilterState = useDeferredValue(filterState);
@@ -63,14 +63,6 @@ const NodesPage = (): JSX.Element => {
[deferredFilterState, getNodes, nodeFilter],
);
useEffect(() => {
if (!connection) return;
connection.events.onTraceRoutePacket.subscribe(handleTraceroute);
return () => {
connection.events.onTraceRoutePacket.unsubscribe(handleTraceroute);
};
}, [connection]);
const handleTraceroute = useCallback(
(traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>) => {
setSelectedTraceroute(traceroute);
@@ -78,17 +70,11 @@ const NodesPage = (): JSX.Element => {
[],
);
useEffect(() => {
if (!connection) return;
connection.events.onPositionPacket.subscribe(handleLocation);
return () => {
connection.events.onPositionPacket.subscribe(handleLocation);
};
}, [connection]);
const handleLocation = useCallback(
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
if (location.to.valueOf() !== hardware.myNodeNum) return;
if (location.to.valueOf() !== hardware.myNodeNum) {
return;
}
setSelectedLocation(location);
},
[hardware.myNodeNum],
@@ -99,6 +85,26 @@ const NodesPage = (): JSX.Element => {
setDialogOpen("nodeDetails", true);
}
useEffect(() => {
if (!connection) {
return;
}
connection.events.onTraceRoutePacket.subscribe(handleTraceroute);
return () => {
connection.events.onTraceRoutePacket.unsubscribe(handleTraceroute);
};
}, [connection, handleTraceroute]);
useEffect(() => {
if (!connection) {
return;
}
connection.events.onPositionPacket.subscribe(handleLocation);
return () => {
connection.events.onPositionPacket.subscribe(handleLocation);
};
}, [connection, handleLocation]);
const tableHeadings: Heading[] = [
{ title: "", sortable: false },
{ title: t("nodesTable.headings.longName"), sortable: true },
@@ -111,10 +117,11 @@ const NodesPage = (): JSX.Element => {
];
const tableRows: DataRow[] = filteredNodes.map((node) => {
const macAddress = base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? t("unknown.shortName");
const macAddress =
base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? t("unknown.shortName");
return {
id: node.num,
@@ -138,8 +145,6 @@ const NodesPage = (): JSX.Element => {
evt.key === "Enter" && handleNodeInfoDialog(node.num);
}}
className="cursor-pointer underline ml-2 whitespace-break-spaces"
tabIndex={0}
role="button"
>
{node.user?.longName ?? numberToHexUnpadded(node.num)}
</h1>
@@ -153,10 +158,10 @@ const NodesPage = (): JSX.Element => {
? node?.viaMqtt === false && node.hopsAway === 0
? t("nodesTable.connectionStatus.direct")
: `${node.hopsAway?.toString()} ${
node.hopsAway ?? 0 > 1
? t("unit.hop.plural")
: t("unit.hops_one")
} ${t("nodesTable.connectionStatus.away")}`
(node.hopsAway ?? 0 > 1)
? t("unit.hop.plural")
: t("unit.hops_one")
} ${t("nodesTable.connectionStatus.away")}`
: t("nodesTable.connectionStatus.unknown")}
{node?.viaMqtt === true
? t("nodesTable.connectionStatus.viaMqtt")
@@ -168,14 +173,14 @@ const NodesPage = (): JSX.Element => {
{
content: (
<Mono>
{node.lastHeard === 0
? <p>{t("nodesTable.lastHeardStatus.never")}</p>
: (
<TimeAgo
timestamp={node.lastHeard * 1000}
locale={currentLanguage?.code}
/>
)}
{node.lastHeard === 0 ? (
<p>{t("nodesTable.lastHeardStatus.never")}</p>
) : (
<TimeAgo
timestamp={node.lastHeard * 1000}
locale={currentLanguage?.code}
/>
)}
</Mono>
),
sortValue: node.lastHeard,
@@ -183,9 +188,11 @@ const NodesPage = (): JSX.Element => {
{
content: (
<Mono>
{node.user?.publicKey && node.user?.publicKey.length > 0
? <LockIcon className="text-green-600 mx-auto" />
: <LockOpenIcon className="text-yellow-300 mx-auto" />}
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
<LockIcon className="text-green-600 mx-auto" />
) : (
<LockOpenIcon className="text-yellow-300 mx-auto" />
)}
</Mono>
),
sortValue: "", // Non-sortable column
@@ -194,11 +201,8 @@ const NodesPage = (): JSX.Element => {
content: (
<Mono>
{node.snr}
{t("unit.dbm")}/
{Math.min(
Math.max((node.snr + 10) * 5, 0),
100,
)}%/{/* Percentage */}
{t("unit.dbm")}/{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}
%/{/* Percentage */}
{(node.snr + 10) * 5}
{t("unit.raw")}
</Mono>
@@ -207,9 +211,7 @@ const NodesPage = (): JSX.Element => {
},
{
content: (
<Mono>
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>
),
sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0],
},
@@ -222,61 +224,54 @@ const NodesPage = (): JSX.Element => {
});
return (
<>
<PageLayout
label=""
leftBar={<Sidebar />}
>
<div className="pl-2 pt-2 flex flex-row">
<div className="flex-1 mr-2">
<Input
placeholder={t("search.nodes")}
value={filterState.nodeName}
className="bg-transparent"
showClearButton={!!filterState.nodeName}
onChange={(e) =>
setFilterState((prev) => ({
...prev,
nodeName: e.target.value,
}))}
/>
</div>
<div className="flex justify-end">
<FilterControl
filterState={filterState}
defaultFilterValues={defaultFilterValues}
setFilterState={setFilterState}
isDirty={isFilterDirty(filterState)}
parameters={{
popoverContentProps: {
side: "bottom",
align: "end",
sideOffset: 12,
},
popoverTriggerClassName: "mr-1 p-2",
showTextSearch: false,
}}
/>
</div>
</div>
<div className="overflow-y-auto">
<Table
headings={tableHeadings}
rows={tableRows}
/>
<TracerouteResponseDialog
traceroute={selectedTraceroute}
open={!!selectedTraceroute}
onOpenChange={() => setSelectedTraceroute(undefined)}
/>
<LocationResponseDialog
location={selectedLocation}
open={!!selectedLocation}
onOpenChange={() => setSelectedLocation(undefined)}
<PageLayout label="" leftBar={<Sidebar />}>
<div className="pl-2 pt-2 flex flex-row">
<div className="flex-1 mr-2">
<Input
placeholder={t("search.nodes")}
value={filterState.nodeName}
className="bg-transparent"
showClearButton={!!filterState.nodeName}
onChange={(e) =>
setFilterState((prev) => ({
...prev,
nodeName: e.target.value,
}))
}
/>
</div>
</PageLayout>
</>
<div className="flex justify-end">
<FilterControl
filterState={filterState}
defaultFilterValues={defaultFilterValues}
setFilterState={setFilterState}
isDirty={isFilterDirty(filterState)}
parameters={{
popoverContentProps: {
side: "bottom",
align: "end",
sideOffset: 12,
},
popoverTriggerClassName: "mr-1 p-2",
showTextSearch: false,
}}
/>
</div>
</div>
<div className="overflow-y-auto">
<Table headings={tableHeadings} rows={tableRows} />
<TracerouteResponseDialog
traceroute={selectedTraceroute}
open={!!selectedTraceroute}
onOpenChange={() => setSelectedTraceroute(undefined)}
/>
<LocationResponseDialog
location={selectedLocation}
open={!!selectedLocation}
onOpenChange={() => setSelectedLocation(undefined)}
/>
</div>
</PageLayout>
);
};