feat(connections): Add connections page (replaces new device dialog) (#919)

* feat(conn): add connection screen and logic

* fixes from code review

* force https

* code review fixes

* add http for self testing

* enable deviceStore persistance

* added translations

* disabled feature flag

* i18n updates

* chore: add new folders to biome config (#910)

* chore(i18n): New Crowdin Translations by GitHub Action (#908)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>

* fix: use correct deprecated GPS coordinate format enum (#917)

The Config_DisplayConfig_GpsCoordinateFormat export doesn't exist in the protobufs package. The correct export is Config_DisplayConfig_DeprecatedGpsCoordinateFormat, which matches what's used in the validation schema.

Added TODO comment explaining that this field is deprecated since protobufs 2.7.4 and should be migrated to DeviceUIConfig.gps_format when DeviceUI settings are implemented.

* style: fix line wrapping for GPS coordinate format enum (#918)

- Split long enum reference across multiple lines to improve code readability
- Maintains consistent code formatting standards without changing functionality

* fix(core): ensure core package works in browser (#923)

* fix(core): ensure core package works in browser

* style(core): revert new line removal

* fix: add @serialport/bindings-cpp to onlyBuiltDependencies (#914)

* feat(ui): Add UI library (#900)

* feat: scaffold UI library

* Update packages/ui/src/components/theme-provider.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add lock file

* lint/formatting fixes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* formatting/linting fixes

* fixed some paring logic

* fixed connection issue with serial

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com>
Co-authored-by: Azarattum <43073346+Azarattum@users.noreply.github.com>
Co-authored-by: Ben Allfree <ben@benallfree.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Dan Ditomaso
2025-11-03 20:24:24 -05:00
committed by GitHub
co-authored by Copilot github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Crowdin Bot Kamil Dzieniszewski Azarattum Ben Allfree
parent a1a646983e
commit 1af1295b8f
69 changed files with 3628 additions and 1881 deletions
+5 -8
View File
@@ -1,15 +1,13 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
import { Toaster } from "@components/Toaster.tsx";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import Footer from "@components/UI/Footer.tsx";
import { useTheme } from "@core/hooks/useTheme.ts";
import { SidebarProvider, useAppStore, useDeviceStore } from "@core/stores";
// import { SidebarProvider, ThemeProvider } from "@meshtastic/ui";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import { Connections } from "@pages/Connections/index.tsx";
import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ErrorBoundary } from "react-error-boundary";
@@ -19,20 +17,19 @@ export function App() {
useTheme();
const { getDevice } = useDeviceStore();
const { selectedDeviceId, setConnectDialogOpen, connectDialogOpen } =
useAppStore();
const { selectedDeviceId } = useAppStore();
const device = getDevice(selectedDeviceId);
return (
// <ThemeProvider defaultTheme="system" storageKey="theme">
<ErrorBoundary FallbackComponent={ErrorPage}>
<NewDeviceDialog
{/* <NewDeviceDialog
open={connectDialogOpen}
onOpenChange={(open) => {
setConnectDialogOpen(open);
}}
/>
/> */}
<Toaster />
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper deviceId={selectedDeviceId}>
@@ -53,7 +50,7 @@ export function App() {
</div>
) : (
<>
<Dashboard />
<Connections />
<Footer />
</>
)}
@@ -0,0 +1,19 @@
import { Badge } from "../UI/Badge.tsx";
export function SupportBadge({
supported,
labelSupported,
labelUnsupported,
}: {
supported: boolean;
labelSupported: string;
labelUnsupported: string;
}) {
return (
<div className="flex items-center gap-2">
<Badge variant={supported ? "secondary" : "destructive"}>
{supported ? labelSupported : labelUnsupported}
</Badge>
</div>
);
}
@@ -1,5 +1,8 @@
import type { ConnectionStatus } from "@app/core/stores/deviceStore/types.ts";
import { cn } from "@core/utils/cn.ts";
import { useNavigate } from "@tanstack/react-router";
import {
ChevronRight,
CpuIcon,
Languages,
type LucideIcon,
@@ -29,6 +32,8 @@ interface DeviceInfoPanelProps {
setDialogOpen: () => void;
setCommandPaletteOpen: () => void;
disableHover?: boolean;
connectionStatus?: ConnectionStatus;
connectionName?: string;
}
interface InfoDisplayItem {
@@ -54,10 +59,36 @@ export const DeviceInfoPanel = ({
isCollapsed,
setCommandPaletteOpen,
disableHover = false,
connectionStatus,
connectionName,
}: DeviceInfoPanelProps) => {
const { t } = useTranslation();
const navigate = useNavigate({ from: "/" });
const { batteryLevel, voltage } = deviceMetrics;
const getStatusColor = (status?: ConnectionStatus): string => {
if (!status) {
return "bg-gray-400";
}
switch (status) {
case "connected":
return "bg-emerald-500";
case "connecting":
return "bg-amber-500";
case "error":
return "bg-red-500";
default:
return "bg-gray-400";
}
};
const getStatusLabel = (status?: ConnectionStatus): string => {
if (!status) {
return t("unknown.notAvailable", "N/A");
}
return status;
};
const deviceInfoItems: InfoDisplayItem[] = [
{
id: "battery",
@@ -129,6 +160,45 @@ export const DeviceInfoPanel = ({
)}
</div>
{connectionStatus && (
<button
type="button"
onClick={() => navigate({ to: "/connections" })}
aria-label={t("navigation.manageConnections", "Manage connections")}
className={cn(
"group flex items-center gap-2 px-1 py-2 flex-shrink-0 rounded-md w-full",
"transition-colors duration-150",
"hover:bg-gray-100 dark:hover:bg-gray-700",
"focus:outline-none focus:ring-2 focus:ring-accent",
isCollapsed && "justify-center",
)}
>
<span
className={cn(
"h-2.5 w-2.5 ml-2 rounded-full flex-shrink-0",
getStatusColor(connectionStatus),
)}
aria-hidden="true"
/>
{!isCollapsed && (
<>
<div className="flex flex-col min-w-0 flex-1 text-left">
<Subtle className="text-xs truncate transition-colors duration-150 group-hover:text-gray-800 dark:group-hover:text-gray-100">
{connectionName || "Connection"}
</Subtle>
<Subtle className="text-xs capitalize text-gray-500 dark:text-gray-400 transition-colors duration-150">
{getStatusLabel(connectionStatus)}
</Subtle>
</div>
<ChevronRight
size={14}
className="flex-shrink-0 text-gray-400 dark:text-gray-500 transition-colors duration-150 group-hover:text-gray-600 dark:group-hover:text-gray-300"
/>
</>
)}
</button>
)}
{!isCollapsed && (
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
)}
@@ -0,0 +1,671 @@
import { SupportBadge } from "@app/components/Badge/SupportedBadge.tsx";
import { Switch } from "@app/components/UI/Switch.tsx";
import type { NewConnection } from "@app/core/stores/deviceStore/types.ts";
import { testHttpReachable } from "@app/pages/Connections/utils";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import {
type BrowserFeature,
useBrowserFeatureDetection,
} from "@core/hooks/useBrowserFeatureDetection.ts";
import { useToast } from "@core/hooks/useToast.ts";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import {
AlertCircle,
Bluetooth,
Cable,
CheckCircle2,
Globe,
Loader2,
type LucideIcon,
MousePointerClick,
XCircle,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useReducer } from "react";
import { Trans, useTranslation } from "react-i18next";
import { DialogWrapper } from "../DialogWrapper.tsx";
import { urlOrIpv4Schema } from "./validation.ts";
type TabKey = "http" | "bluetooth" | "serial";
type TestingStatus = "idle" | "testing" | "success" | "failure";
type DialogState = {
tab: TabKey;
name: string;
protocol: "http" | "https";
url: string;
testStatus: TestingStatus;
btSelected: { id: string; name?: string; device?: BluetoothDevice } | undefined;
serialSelected: { vendorId?: number; productId?: number } | undefined;
};
type DialogAction =
| { type: "RESET"; payload?: { isHTTPS?: boolean } }
| { type: "SET_TAB"; payload: TabKey }
| { type: "SET_NAME"; payload: string }
| { type: "SET_PROTOCOL"; payload: "http" | "https" }
| { type: "SET_URL"; payload: string }
| { type: "SET_TEST_STATUS"; payload: TestingStatus }
| {
type: "SET_BT_SELECTED";
payload: { id: string; name?: string; device?: BluetoothDevice } | undefined;
}
| {
type: "SET_SERIAL_SELECTED";
payload: { vendorId?: number; productId?: number } | undefined;
}
| { type: "SET_URL_AND_RESET_TEST"; payload: string };
interface FeatureErrorProps {
missingFeatures: BrowserFeature[];
tabId: "bluetooth" | "serial";
}
type Pane = {
children: () => React.ReactNode;
placeholder: string;
validate: () => boolean;
build: () => NewConnection | null;
};
const featureErrors: Record<BrowserFeature, { href: string; i18nKey: string }> =
{
"Web Bluetooth": {
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
i18nKey: "addConnection.validation.requiresWebBluetooth",
},
"Web Serial": {
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
i18nKey: "addConnection.validation.requiresWebSerial",
},
"Secure Context": {
href: "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
i18nKey: "addConnection.validation.requiresSecureContext",
},
};
const FeatureErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
if (missingFeatures.length === 0) {
return null;
}
const browserFeatures = missingFeatures.filter(
(feature) => feature !== "Secure Context",
);
const needsSecureContext = missingFeatures.includes("Secure Context");
const needsFeature =
tabId === "bluetooth" && browserFeatures.includes("Web Bluetooth")
? "Web Bluetooth"
: tabId === "serial" && browserFeatures.includes("Web Serial")
? "Web Serial"
: undefined;
return (
<div className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md text-sm mt-4">
<div className="flex items-center gap-2 w-full">
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
<div className="flex flex-col gap-3">
<div className="text-sm text-white">
{needsFeature && (
<Trans
i18nKey={featureErrors[needsFeature].i18nKey}
components={[
<Link
key="0"
href={featureErrors[needsFeature].href}
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
/>,
]}
/>
)}
{needsFeature && needsSecureContext && " "}
{needsSecureContext && (
<Trans
i18nKey={
browserFeatures.length > 0
? "addConnection.validation.additionallyRequiresSecureContext"
: "addConnection.validation.requiresSecureContext"
}
components={{
"0": (
<Link
href={featureErrors["Secure Context"].href}
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
/>
),
}}
/>
)}
</div>
</div>
</div>
</div>
);
};
const initialState: DialogState = {
tab: "http",
name: "",
protocol: "http",
url: "",
testStatus: "idle",
btSelected: undefined,
serialSelected: undefined,
};
export const createInitialDialogState = (
overrides?: Partial<DialogState>,
): DialogState => {
return { ...initialState, ...(overrides ?? {}) };
};
export const dialogStateInitializer = (
overrides?: Partial<DialogState>,
): DialogState => createInitialDialogState(overrides);
function dialogReducer(state: DialogState, action: DialogAction): DialogState {
switch (action.type) {
case "RESET":
return createInitialDialogState(
action.payload?.isHTTPS ? { protocol: "https" } : {},
);
case "SET_TAB":
return { ...state, tab: action.payload };
case "SET_NAME":
return { ...state, name: action.payload };
case "SET_PROTOCOL":
return { ...state, protocol: action.payload };
case "SET_URL":
return { ...state, url: action.payload };
case "SET_TEST_STATUS":
return { ...state, testStatus: action.payload };
case "SET_BT_SELECTED":
return { ...state, btSelected: action.payload };
case "SET_SERIAL_SELECTED":
return { ...state, serialSelected: action.payload };
case "SET_URL_AND_RESET_TEST":
return { ...state, url: action.payload, testStatus: "idle" };
default:
return state;
}
}
function PickerRow({
label,
buttonText,
onPick,
disabled,
display,
helper,
}: {
label: string;
buttonText: string;
onPick: () => void | Promise<void>;
disabled?: boolean;
display: string;
helper?: string;
}) {
return (
<div className="grid gap-2">
<Label>{label}</Label>
<div className="flex items-center gap-2">
<Button
variant="subtle"
className="gap-2"
onClick={onPick}
disabled={disabled}
>
<MousePointerClick className="h-4 w-4" />
{buttonText}
</Button>
<div className="text-sm text-slate-500 dark:text-slate-400 truncate">
{display}
</div>
</div>
{helper ? (
<p className="text-xs text-slate-500 dark:text-slate-400">{helper}</p>
) : null}
</div>
);
}
const TAB_META: Array<{ key: TabKey; label: string; Icon: LucideIcon }> = [
{ key: "http", label: "HTTP", Icon: Globe },
{ key: "bluetooth", label: "Bluetooth", Icon: Bluetooth },
{ key: "serial", label: "Serial", Icon: Cable },
];
export default function AddConnectionDialog({
open = false,
onOpenChange = () => {},
onSave = async () => {},
isHTTPS = false,
}: {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSave?: (conn: NewConnection, device?: BluetoothDevice) => Promise<void>;
isHTTPS?: boolean;
}) {
const { toast } = useToast();
const [state, dispatch] = useReducer(dialogReducer, initialState, () =>
dialogStateInitializer(isHTTPS ? { protocol: "https" } : {}),
);
const { unsupported } = useBrowserFeatureDetection();
const { t } = useTranslation();
const bluetoothSupported =
typeof navigator !== "undefined" && "bluetooth" in navigator;
const serialSupported =
typeof navigator !== "undefined" && "serial" in navigator;
const isURLHTTPS = isHTTPS;
const reset = useCallback(() => {
dispatch({ type: "RESET", payload: { isHTTPS } });
}, [isHTTPS]);
useEffect(() => {
if (!open) {
reset();
}
}, [reset, open]);
const makeToastErrorHandler = useCallback(
(titlePrefix: string) =>
(err: unknown): void => {
if (err && (err as { name?: string }).name === "NotFoundError") {
return; // user canceled
}
const message = err instanceof Error ? err.message : String(err);
toast({ title: `${titlePrefix} error`, description: message });
},
[toast],
);
const handlePickBluetooth = useCallback(async () => {
if (!bluetoothSupported) {
toast({
title: t("addConnection.bluetoothConnection.notSupported.title"),
description: t(
"addConnection.bluetoothConnection.notSupported.description",
),
});
return;
}
try {
const device = await navigator.bluetooth.requestDevice({
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
});
dispatch({
type: "SET_BT_SELECTED",
payload: { id: device.id, name: device.name ?? undefined, device },
});
if (!state.name || state.name === "") {
dispatch({
type: "SET_NAME",
payload: device.name
? t("addConnection.bluetoothConnection.short", {
deviceName: device.name,
})
: t("addConnection.bluetoothConnection.long"),
});
}
toast({
title: t("addConnection.bluetoothConnection.selected"),
description: device.name || device.id,
});
} catch (err) {
makeToastErrorHandler("Bluetooth")(err);
}
}, [bluetoothSupported, state.name, toast, makeToastErrorHandler, t]);
const handlePickSerial = useCallback(async () => {
if (!serialSupported) {
toast({
title: t("addConnection.serialConnection.notSupported.title"),
description: t(
"addConnection.serialConnection.notSupported.description",
),
});
return;
}
try {
const port = await (
navigator as Navigator & {
serial: {
requestPort: (o: Record<string, unknown>) => Promise<SerialPort>;
};
}
).serial.requestPort({});
const info =
(
port as SerialPort & {
getInfo?: () => { usbVendorId?: number; usbProductId?: number };
}
).getInfo?.() ?? {};
dispatch({
type: "SET_SERIAL_SELECTED",
payload: {
vendorId: info.usbVendorId,
productId: info.usbProductId,
},
});
if (!state.name || state.name === "") {
const v = info.usbVendorId ? info.usbVendorId.toString(16) : "?";
const p = info.usbProductId ? info.usbProductId.toString(16) : "?";
dispatch({ type: "SET_NAME", payload: `Serial: ${v}:${p}` });
}
toast({
title: t("addConnection.serialConnection.portSelected.title"),
description: t(
"addConnection.serialConnection.portSelected.description",
),
});
} catch (err) {
makeToastErrorHandler("Serial")(err);
}
}, [serialSupported, state.name, toast, makeToastErrorHandler, t]);
const handleTestHttp = useCallback(async () => {
const fullUrl = `${state.protocol}://${state.url}`;
const validatedURL = urlOrIpv4Schema.safeParse(fullUrl);
if (validatedURL.success === false) {
toast({
title: t("addConnection.httpConnection.invalidUrl.title"),
description: t("addConnection.httpConnection.invalidUrl.description"),
});
return;
}
dispatch({ type: "SET_TEST_STATUS", payload: "testing" });
const reachable = await testHttpReachable(validatedURL.data);
if (reachable) {
dispatch({ type: "SET_TEST_STATUS", payload: "success" });
toast({
title: t("addConnection.httpConnection.connectionTest.success.title"),
description: t(
"addConnection.httpConnection.connectionTest.success.description",
),
});
} else {
dispatch({ type: "SET_TEST_STATUS", payload: "failure" });
toast({
title: t("addConnection.httpConnection.connectionTest.failure.title"),
description: t(
"addConnection.httpConnection.connectionTest.failure.description",
),
});
}
}, [state.protocol, state.url, toast, t]);
const PANES: Record<TabKey, Pane> = useMemo(
() => ({
http: {
placeholder: t("addConnection.httpConnection.namePlaceholder"),
children: () => (
<div className="flex flex-col gap-4">
<Label htmlFor="url">
{t("addConnection.httpConnection.heading")}
</Label>
<Input
id={"url"}
inputMode="url"
placeholder={t("addConnection.httpConnection.inputPlaceholder")}
prefix={`${state.protocol}://`}
value={state.url}
onChange={(e) => {
dispatch({
type: "SET_URL_AND_RESET_TEST",
payload: e.target.value,
});
}}
/>
<div className="flex items-center gap-2 mt-1">
<Switch
value={state.protocol}
disabled={!!isURLHTTPS}
checked={state.protocol === "https"}
onCheckedChange={(value) => {
dispatch({
type: "SET_PROTOCOL",
payload: value ? "https" : "http",
});
dispatch({ type: "SET_TEST_STATUS", payload: "idle" });
}}
></Switch>
<Label>{t("addConnection.httpConnection.useHttps")}</Label>
</div>
<div className="flex items-center gap-2 mt-4">
<Button
variant="subtle"
className="gap-2"
onClick={handleTestHttp}
disabled={
urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`)
.success === false || state.testStatus === "testing"
}
>
{state.testStatus === "testing" ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t(
"addConnection.httpConnection.connectionTest.button.loading",
)}
</>
) : (
<>
<MousePointerClick className="h-4 w-4" />
{t(
"addConnection.httpConnection.connectionTest.button.label",
)}
</>
)}
</Button>
{state.testStatus === "success" && (
<div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" />
{t("addConnection.httpConnection.connectionTest.reachable")}
</div>
)}
{state.testStatus === "failure" && (
<div className="flex items-center gap-1 text-sm text-red-600 dark:text-red-400">
<XCircle className="h-4 w-4" />
{t(
"addConnection.httpConnection.connectionTest.notReachable",
)}
</div>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
{t("addConnection.httpConnection.connectionTest.description")}
</p>
</div>
),
validate: () =>
urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`)
.success === true && state.testStatus === "success",
build: () => ({
type: "http",
name: state.name.trim(),
url: `${state.protocol}://${state.url.trim()}`,
}),
},
bluetooth: {
placeholder: "My Bluetooth Node",
children: () => (
<>
<SupportBadge
supported={bluetoothSupported}
labelSupported={t(
"addConnection.bluetoothConnection.supported.title",
)}
labelUnsupported={t(
"addConnection.bluetoothConnection.notSupported.title",
)}
/>
<PickerRow
label={t("addConnection.bluetoothConnection.device")}
buttonText={t("addConnection.bluetoothConnection.selectDevice")}
onPick={handlePickBluetooth}
disabled={!bluetoothSupported}
display={
state.btSelected
? state.btSelected.name || state.btSelected.id
: t("addConnection.bluetoothConnection.notSelected")
}
helper={t("addConnection.bluetoothConnection.helperText")}
/>
<FeatureErrorMessage
missingFeatures={unsupported}
tabId="bluetooth"
/>
</>
),
validate: () => state.name.trim().length > 0 && !!state.btSelected,
build: () => ({
type: "bluetooth",
name: state.name.trim(),
deviceId: state.btSelected?.id,
deviceName: state.btSelected?.name,
gattServiceUUID: TransportWebBluetooth.ServiceUuid,
}),
},
serial: {
placeholder: t("addConnection.serialConnection.namePlaceholder"),
children: () => (
<>
<SupportBadge
supported={serialSupported}
labelSupported={t(
"addConnection.serialConnection.supported.title",
)}
labelUnsupported={t(
"addConnection.serialConnection.notSupported.title",
)}
/>
<PickerRow
label={t("addConnection.serialConnection.port")}
buttonText={t("addConnection.serialConnection.selectPort")}
onPick={handlePickSerial}
disabled={!serialSupported}
display={
state.serialSelected
? t("addConnection.serialConnection.deviceName", {
vendorId:
state.serialSelected.vendorId?.toString(16) ?? "?",
productId:
state.serialSelected.productId?.toString(16) ?? "?",
})
: t("addConnection.serialConnection.notSelected")
}
helper={t("addConnection.serialConnection.helperText")}
/>
<FeatureErrorMessage missingFeatures={unsupported} tabId="serial" />
</>
),
validate: () =>
state.name.trim().length > 0 &&
(!!state.serialSelected || !serialSupported),
build: () => ({
type: "serial",
name: state.name.trim(),
usbVendorId: state.serialSelected?.vendorId,
usbProductId: state.serialSelected?.productId,
}),
},
}),
[
state,
bluetoothSupported,
serialSupported,
isURLHTTPS,
handlePickBluetooth,
handlePickSerial,
handleTestHttp,
unsupported,
t,
],
);
const currentPane = PANES[state.tab];
const canCreate = useMemo(() => currentPane.validate(), [currentPane]);
const submit = (fn: (p: NewConnection, device?: BluetoothDevice) => Promise<void>) => async () => {
if (!canCreate) {
return;
}
const payload = currentPane.build();
if (!payload) {
return;
}
const btDevice = state.tab === "bluetooth" ? state.btSelected?.device : undefined;
await fn(payload, btDevice);
};
return (
<DialogWrapper
variant="default"
type="custom"
title={t("addConnection.title")}
description={t("addConnection.description")}
cancelText={t("button.cancel")}
open={open}
onOpenChange={onOpenChange}
>
<Tabs
value={state.tab}
onValueChange={(v) =>
dispatch({ type: "SET_TAB", payload: v as TabKey })
}
>
<TabsList className="grid grid-cols-3">
{TAB_META.map(({ key, label, Icon }) => (
<TabsTrigger key={key} value={key} className="gap-2">
<Icon className="h-4 w-4" />
{label}
</TabsTrigger>
))}
</TabsList>
{TAB_META.map(({ key }) => (
<TabsContent key={key} value={key}>
{state.tab === key ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<Label htmlFor={`name-${state.tab}`}>Name</Label>
<Input
id={`name-${state.tab}`}
value={state.name}
onChange={(evt) =>
dispatch({ type: "SET_NAME", payload: evt.target.value })
}
placeholder={currentPane.placeholder}
/>
</div>
{PANES[key].children()}
<div className="flex justify-end">
<div className="inline-flex rounded-md shadow-sm overflow-hidden border">
<Button
onClick={submit(onSave)}
disabled={!canCreate}
className="rounded-none"
>
{t("button.saveConnection")}
</Button>
</div>
</div>
</div>
) : null}
</TabsContent>
))}
</Tabs>
</DialogWrapper>
);
}
@@ -0,0 +1,27 @@
import z from "zod";
export const urlOrIpv4Schema = z
.string()
.trim()
.refine((val) => {
const input = val.replace(/^https?:\/\//i, ""); // remove protocol for validation
// IPv4 pattern
const ipv4Regex =
/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
// Domain pattern (e.g. example.com, meshtastic.local)
const domainRegex = /^(?!-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,}$/;
// Local domain (e.g. meshtastic.local)
const localDomainRegex = /^(?!-)[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.local$/;
return (
ipv4Regex.test(input) ||
domainRegex.test(input) ||
localDomainRegex.test(input)
);
}, "Must be a valid IPv4 address or domain name")
.transform((val) => {
return /^https?:\/\//i.test(val) ? val : `http://${val}`;
});
@@ -1,140 +0,0 @@
import { create } from "@bufbuild/protobuf";
import { GenericInput } from "@components/Form/FormInput.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useDevice, useNodeDB } from "@core/stores";
import { zodResolver } from "@hookform/resolvers/zod";
import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import z from "zod";
import { Label } from "../UI/Label.tsx";
export interface User {
longName: string;
shortName: string;
}
export interface DeviceNameDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const DeviceNameDialog = ({
open,
onOpenChange,
}: DeviceNameDialogProps) => {
const { t } = useTranslation("dialog");
const { hardware, connection } = useDevice();
const { getNode } = useNodeDB();
const myNode = getNode(hardware.myNodeNum);
const defaultValues = {
shortName: myNode?.user?.shortName ?? "",
longName: myNode?.user?.longName ?? "",
};
const deviceNameSchema = z.object({
longName: z
.string()
.min(1, t("deviceName.validation.longNameMin"))
.max(40, t("deviceName.validation.longNameMax")),
shortName: z
.string()
.min(2, t("deviceName.validation.shortNameMin"))
.max(4, t("deviceName.validation.shortNameMax")),
});
const { getValues, reset, control, handleSubmit } = useForm<User>({
values: defaultValues,
resolver: zodResolver(deviceNameSchema),
});
const onSubmit = handleSubmit((data) => {
connection?.setOwner(
create(Protobuf.Mesh.UserSchema, {
...data,
}),
);
onOpenChange(false);
});
const handleReset = () => {
reset(defaultValues);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("deviceName.title")}</DialogTitle>
<DialogDescription>{t("deviceName.description")}</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div>
<Label htmlFor="longName">{t("deviceName.longName")}</Label>
<GenericInput
control={control}
field={{
name: "longName",
label: t("deviceName.longName"),
type: "text",
properties: {
className: "text-slate-900 dark:text-slate-200",
fieldLength: {
currentValueLength: getValues("longName").length,
max: 40,
min: 1,
showCharacterCount: true,
},
},
}}
/>
</div>
<div>
<Label htmlFor="shortName">{t("deviceName.shortName")}</Label>
<GenericInput
control={control}
field={{
name: "shortName",
label: t("deviceName.shortName"),
type: "text",
properties: {
fieldLength: {
currentValueLength: getValues("shortName").length,
max: 4,
min: 1,
showCharacterCount: true,
},
},
}}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="destructive"
name="reset"
onClick={handleReset}
>
{t("button.reset")}
</Button>
<Button type="submit" name="save">
{t("button.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -3,7 +3,6 @@ import { FactoryResetDeviceDialog } from "@app/components/Dialog/FactoryResetDev
import { ClearAllStoresDialog } from "@components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.tsx";
import { ClientNotificationDialog } from "@components/Dialog/ClientNotificationDialog/ClientNotificationDialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
@@ -47,12 +46,6 @@ export const DialogManager = () => {
setDialogOpen("reboot", false);
}}
/>
<DeviceNameDialog
open={dialog.deviceName}
onOpenChange={(open) => {
setDialogOpen("deviceName", open);
}}
/>
<RemoveNodeDialog
open={dialog.nodeRemoval}
onOpenChange={(open) => {
@@ -105,7 +105,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
)
) {
setChange(
{ type: "channel", index: importIndex[index] ?? 0 },
{ type: "channels", index: importIndex[index] ?? 0 },
payload,
channels.get(importIndex[index] ?? 0),
);
@@ -47,7 +47,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
};
const workingChannel = getChange({
type: "channel",
type: "channels",
index: channel.index,
}) as Protobuf.Channel.Channel | undefined;
const effectiveConfig = workingChannel ?? channel;
@@ -123,11 +123,11 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
});
if (deepCompareConfig(channel, payload, true)) {
removeChange({ type: "channel", index: channel.index });
removeChange({ type: "channels", index: channel.index });
return;
}
setChange({ type: "channel", index: channel.index }, payload, channel);
setChange({ type: "channels", index: channel.index }, payload, channel);
};
const preSharedKeyRegenerate = async () => {
@@ -1,104 +0,0 @@
import { Mono } from "@components/generic/Mono.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
useAppStore,
useDeviceStore,
useMessageStore,
useNodeDBStore,
} from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
export const BLE = ({ closeDialog }: TabElementProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore();
const { addNodeDB } = useNodeDBStore();
const { addMessageStore } = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { t } = useTranslation();
const updateBleDeviceList = useCallback(async (): Promise<void> => {
setBleDevices(await navigator.bluetooth.getDevices());
}, []);
useEffect(() => {
updateBleDeviceList();
}, [updateBleDeviceList]);
const onConnect = async (bleDevice: BluetoothDevice) => {
const id = randId();
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
const device = addDevice(id);
const nodeDB = addNodeDB(id);
const messageStore = addMessageStore(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore, nodeDB);
const HEARTBEAT_INTERVAL = 5 * 60 * 1000;
connection.setHeartbeatInterval(HEARTBEAT_INTERVAL);
closeDialog();
};
return (
<fieldset
className="flex w-full flex-col gap-2 p-4"
disabled={connectionInProgress}
>
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
{bleDevices.map((device) => (
<Button
key={device.id}
variant="default"
onClick={() => {
setConnectionInProgress(true);
onConnect(device);
}}
>
{device.name}
</Button>
))}
{bleDevices.length === 0 && (
<Mono className="m-auto select-none">
{t("newDeviceDialog.bluetoothConnection.noDevicesPaired")}
</Mono>
)}
</div>
<Button
variant="default"
onClick={async () => {
await navigator.bluetooth
.requestDevice({
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
})
.then((device) => {
const exists = bleDevices.findIndex((d) => d.id === device.id);
if (exists === -1) {
setBleDevices(bleDevices.concat(device));
}
})
.catch((error: Error) => {
console.error("Error requesting device:", error);
setConnectionInProgress(false);
})
.finally(() => {
setConnectionInProgress(false);
});
}}
>
<span>{t("newDeviceDialog.bluetoothConnection.newDeviceButton")}</span>
</Button>
</fieldset>
);
};
@@ -1,103 +0,0 @@
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@core/stores", () => ({
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
useDeviceStore: vi.fn(() => ({
addDevice: vi.fn(() => ({ addConnection: vi.fn() })),
})),
useMessageStore: vi.fn(() => ({
addMessageStore: vi.fn(),
})),
useNodeDBStore: vi.fn(() => ({
addNodeDB: vi.fn(),
})),
}));
vi.mock("@core/utils/randId.ts", () => ({
randId: vi.fn(() => "mock-id"),
}));
vi.mock("@meshtastic/transport-http", () => ({
TransportHTTP: {
create: vi.fn(() => Promise.resolve({})),
},
}));
vi.mock("@meshtastic/core", () => ({
MeshDevice: vi.fn(() => ({
configure: vi.fn(),
})),
}));
describe("HTTP Component", () => {
it("renders correctly", () => {
render(<HTTP closeDialog={vi.fn()} />);
expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(
screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"),
).toBeInTheDocument();
expect(screen.getByText("Use HTTPS")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
});
it("allows input field to be updated", () => {
render(<HTTP closeDialog={vi.fn()} />);
const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: "meshtastic.local" } });
expect(
screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"),
).toBeInTheDocument();
});
it("toggles HTTPS switch and updates prefix", () => {
render(<HTTP closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch");
expect(screen.getByText("http://")).toBeInTheDocument();
fireEvent.click(switchInput);
expect(screen.getByText("https://")).toBeInTheDocument();
fireEvent.click(switchInput);
expect(switchInput).not.toBeChecked();
expect(screen.getByText("http://")).toBeInTheDocument();
});
it("enables HTTPS toggle when location protocol is https", () => {
Object.defineProperty(window, "location", {
value: { protocol: "https:" },
writable: true,
});
render(<HTTP closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch");
expect(switchInput).toBeChecked();
expect(screen.getByText("https://")).toBeInTheDocument();
});
it.skip("submits form and triggers connection process", async () => {
const closeDialog = vi.fn();
render(<HTTP closeDialog={closeDialog} />);
const button = screen.getByRole("button", { name: "Connect" });
expect(button).not.toBeDisabled();
try {
fireEvent.click(button);
await waitFor(() => {
expect(button).toBeDisabled();
expect(closeDialog).toBeCalled();
expect(TransportHTTP.create).toBeCalled();
expect(MeshDevice).toBeCalled();
});
} catch (e) {
console.error(e);
}
});
});
@@ -1,172 +0,0 @@
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import {
useAppStore,
useDeviceStore,
useMessageStore,
useNodeDBStore,
} from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { AlertTriangle } from "lucide-react";
import { useState } from "react";
import { useController, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
interface FormData {
ip: string;
tls: boolean;
}
export const HTTP = ({ closeDialog }: TabElementProps) => {
const { t } = useTranslation("dialog");
const [connectionInProgress, setConnectionInProgress] = useState(false);
const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore();
const { addNodeDB } = useNodeDBStore();
const { addMessageStore } = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { control, handleSubmit, register } = useForm<FormData>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
globalThis.location.hostname,
)
? "meshtastic.local"
: globalThis.location.host,
tls: !!isURLHTTPS,
},
});
const {
field: { value: tlsValue, onChange: setTLS },
} = useController({ name: "tls", control });
const [connectionError, setConnectionError] = useState<{
host: string;
secure: boolean;
} | null>(null);
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
setConnectionError(null);
try {
const id = randId();
const transport = await TransportHTTP.create(data.ip, data.tls);
const device = addDevice(id);
const nodeDB = addNodeDB(id);
const messageStore = addMessageStore(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id);
device.addConnection(connection);
subscribeAll(device, connection, messageStore, nodeDB);
closeDialog();
} catch (error) {
if (error instanceof Error) {
console.error("Connection error:", error);
}
// Capture all connection errors regardless of type
setConnectionError({ host: data.ip, secure: data.tls });
setConnectionInProgress(false);
}
});
return (
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}>
<fieldset
className="flex flex-col gap-2"
style={{ minHeight: "12rem" }}
disabled={connectionInProgress}
>
<div>
<Label>{t("newDeviceDialog.httpConnection.label")}</Label>
<Input
prefix={
tlsValue
? `${t("newDeviceDialog.https")}://`
: `${t("newDeviceDialog.http")}://`
}
placeholder={t("newDeviceDialog.httpConnection.placeholder")}
className="text-slate-900 dark:text-slate-100"
{...register("ip")}
/>
</div>
<div className="mt-2 flex items-center gap-2">
<Switch
onCheckedChange={setTLS}
disabled={isURLHTTPS}
checked={isURLHTTPS || tlsValue}
{...register("tls")}
/>
<Label>{t("newDeviceDialog.useHttps")}</Label>
</div>
{connectionError && (
<div className="mt-2 mb-2 p-3 rounded-md bg-amber-100 border border-amber-300 dark:bg-amber-100 dark:border-amber-300">
<div className="flex gap-2 items-start">
<AlertTriangle
className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600"
size={20}
/>
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
{t("newDeviceDialog.connectionFailedAlert.title")}
</p>
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
{t("newDeviceDialog.connectionFailedAlert.descriptionPrefix")}
{connectionError.secure &&
t("newDeviceDialog.connectionFailedAlert.httpsHint")}
{t("newDeviceDialog.connectionFailedAlert.openLinkPrefix")}
<Link
href={`${
connectionError.secure
? t("newDeviceDialog.https")
: t("newDeviceDialog.http")
}://${connectionError.host}`}
className="underline font-medium text-amber-800 dark:text-amber-800"
>
{`${
connectionError.secure
? t("newDeviceDialog.https")
: t("newDeviceDialog.http")
}://${connectionError.host}`}
</Link>{" "}
{t("newDeviceDialog.connectionFailedAlert.openLinkSuffix")}
{connectionError.secure
? t(
"newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix",
)
: ""}
.{" "}
<Link
href="https://meshtastic.org/docs/software/web-client/#http"
className="underline font-medium text-amber-800 dark:text-amber-800"
>
{t("newDeviceDialog.connectionFailedAlert.learnMoreLink")}
</Link>
</p>
</div>
</div>
</div>
)}
</fieldset>
<Button type="submit" variant="default">
<span>
{connectionInProgress
? t("newDeviceDialog.connecting")
: t("newDeviceDialog.connect")}
</span>
</Button>
</form>
);
};
@@ -1,116 +0,0 @@
import { Mono } from "@components/generic/Mono.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
useAppStore,
useDeviceStore,
useMessageStore,
useNodeDBStore,
} from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
export const Serial = ({ closeDialog }: TabElementProps) => {
const [connectionInProgress, setConnectionInProgress] = useState(false);
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const { addDevice } = useDeviceStore();
const { addNodeDB } = useNodeDBStore();
const { addMessageStore } = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { t } = useTranslation();
const updateSerialPortList = useCallback(async () => {
setSerialPorts(await navigator.serial.getPorts());
}, []);
navigator.serial.addEventListener("connect", () => {
updateSerialPortList();
});
navigator.serial.addEventListener("disconnect", () => {
updateSerialPortList();
});
useEffect(() => {
updateSerialPortList();
}, [updateSerialPortList]);
const onConnect = async (port: SerialPort) => {
const id = randId();
const device = addDevice(id);
const nodeDB = addNodeDB(id);
const messageStore = addMessageStore(id);
setSelectedDevice(id);
const transport = await TransportWebSerial.createFromPort(port);
const connection = new MeshDevice(transport, id);
connection.configure();
device.addConnection(connection);
subscribeAll(device, connection, messageStore, nodeDB);
const HEARTBEAT_INTERVAL = 5 * 60 * 1000;
connection.setHeartbeatInterval(HEARTBEAT_INTERVAL);
closeDialog();
};
return (
<fieldset
className="flex w-full flex-col gap-2 p-4"
disabled={connectionInProgress}
>
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, idx) => {
const { usbProductId, usbVendorId } = port.getInfo();
const vendor = usbVendorId ?? t("unknown.shortName");
const product = usbProductId ?? t("unknown.shortName");
return (
<Button
key={`${vendor}-${product}`}
disabled={port.readable !== null}
variant="default"
onClick={async () => {
setConnectionInProgress(true);
await onConnect(port);
// No need to setConnectionInProgress(false) here as closeDialog() unmounts.
}}
>
{t("newDeviceDialog.serialConnection.deviceIdentifier", {
vendorId: vendor,
productId: product,
index: idx,
})}
</Button>
);
})}
{serialPorts.length === 0 && (
<Mono className="m-auto select-none">
{t("newDeviceDialog.serialConnection.noDevicesPaired")}
</Mono>
)}
</div>
<Button
variant="default"
onClick={async () => {
await navigator.serial
.requestPort()
.then((port) => {
setSerialPorts(serialPorts.concat(port));
// No need to setConnectionInProgress(false) here if requestPort is quick
})
.catch((error: Error) => {
console.error("Error requesting port:", error);
})
.finally(() => {
setConnectionInProgress(false);
});
}}
>
<span>{t("newDeviceDialog.serialConnection.newDeviceButton")}</span>
</Button>
</fieldset>
);
};
@@ -0,0 +1,38 @@
import { Button } from "@app/components/UI/Button";
import type { Connection } from "@app/core/stores/deviceStore/types";
export function ConnectionStatusBadge({
status,
}: {
status: Connection["status"];
}) {
let color = "";
switch (status) {
case "connected":
color = "bg-emerald-500";
break;
case "connecting":
color = "bg-amber-500";
break;
case "online":
color = "bg-blue-500";
break;
case "error":
color = "bg-red-500";
break;
default:
color = "bg-gray-400";
}
return (
<Button variant="subtle" className="inline-flex items-center gap-2">
<span
className={`h-2.5 w-2.5 rounded-full ${color}`}
aria-hidden="true"
/>
<span className="text-xs capitalize text-slate-500 dark:text-slate-400">
{status}
</span>
</Button>
);
}
+10
View File
@@ -6,6 +6,7 @@ import {
type Page,
useAppStore,
useDevice,
useDeviceStore,
useNodeDB,
useSidebar,
} from "@core/stores";
@@ -70,11 +71,18 @@ export const Sidebar = ({ children }: SidebarProps) => {
const { hardware, metadata, unreadCounts, setDialogOpen } = useDevice();
const { getNode, getNodesLength } = useNodeDB();
const { setCommandPaletteOpen } = useAppStore();
const savedConnections = useDeviceStore((s) => s.savedConnections);
const myNode = getNode(hardware.myNodeNum);
const { isCollapsed } = useSidebar();
const { t } = useTranslation("ui");
const navigate = useNavigate({ from: "/" });
// Get the active connection (connected > default > first)
const activeConnection =
savedConnections.find((c) => c.status === "connected") ||
savedConnections.find((c) => c.isDefault) ||
savedConnections[0];
const pathname = useLocation({
select: (location) => location.pathname.replace(/^\//, ""),
});
@@ -210,6 +218,8 @@ export const Sidebar = ({ children }: SidebarProps) => {
? Math.abs(myNode.deviceMetrics?.voltage)
: undefined,
}}
connectionStatus={activeConnection?.status}
connectionName={activeConnection?.name}
/>
)}
</div>
@@ -0,0 +1,149 @@
import { cn } from "@core/utils/cn.ts";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as React from "react";
import { buttonVariants } from "./Button.tsx";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = ({
children,
...props
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
<AlertDialogPrimitive.Portal {...props}>
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
{children}
</div>
</AlertDialogPrimitive.Portal>
);
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-xs transition-opacity animate-in fade-in",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full max-w-lg scale-100 gap-4 border border-slate-200 bg-white p-6 opacity-100 shadow-lg animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 dark:border-slate-700 dark:bg-slate-800",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold text-slate-900 dark:text-slate-100",
className,
)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
};
+35
View File
@@ -0,0 +1,35 @@
import { cn } from "@core/utils/cn.ts";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-slate-900 text-white hover:bg-slate-900/80 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/80",
secondary:
"border-transparent bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-800/80",
destructive:
"border-transparent bg-red-500 text-white hover:bg-red-500/80 dark:bg-red-900 dark:text-red-50 dark:hover:bg-red-900/80",
outline: "text-slate-900 dark:text-slate-100",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+85
View File
@@ -0,0 +1,85 @@
import { cn } from "@core/utils/cn.ts";
import * as React from "react";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-slate-200 bg-white text-slate-900 shadow-sm dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
+2 -2
View File
@@ -48,8 +48,8 @@ export function ErrorPage({ error }: { error: Error }) {
</P>
<P>
<Trans
i18nKey="errorPage.dashboardLink"
components={[<Link key="dashboard" href="/" />]}
i18nKey="errorPage.connectionsLink"
components={[<Link key="connections" href="/" />]}
/>
</P>
</div>
+232
View File
@@ -0,0 +1,232 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
/**
* A minimal serializable wrapper so we can store metadata alongside items.
*/
type PersistedPayload<J = unknown> = {
v: 1; // schema version
capacity: number; // last used capacity (for info/migrations)
items: J[]; // serialized items (MRU -> LRU)
};
export type UseLRUListOptions<T, J = unknown> = {
/** localStorage key */
key: string;
/** max number of items to keep (>=1) */
capacity: number;
/** optional initial items used when storage is empty/invalid */
initial?: T[];
/** equality to de-duplicate items; default: Object.is */
eq?: (a: T, b: T) => boolean;
/** convert T -> JSON-safe type; default: (x) => x as unknown as J */
toJSON?: (t: T) => J;
/** convert JSON-safe type -> T; default: (x) => x as unknown as T */
fromJSON?: (j: J) => T;
/** storage impl (for tests); default: window.localStorage */
storage?: Storage;
/** listen to storage events and live-sync across tabs/windows; default: true */
syncTabs?: boolean;
};
export type UseLRUListReturn<T> = {
/** Items ordered MRU -> LRU */
items: T[];
/** Add or "touch" an item (move to MRU); inserts if missing */
add: (item: T) => void;
/** Remove a matching item (no-op if missing) */
remove: (item: T) => void;
/** Clear all items */
clear: () => void;
/** Whether a matching item exists */
has: (item: T) => boolean;
/** Replace the entire list (applies LRU trimming) */
replaceAll: (next: T[]) => void;
/** Current capacity (for information) */
capacity: number;
};
/**
* useLRUList<T> – maintains a most-recently-used list and persists it to localStorage.
*
* MRU is index 0. Adding an existing item "touches" it (moves to front).
*/
export function useLRUList<T, J = unknown>(
opts: UseLRUListOptions<T, J>,
): UseLRUListReturn<T> {
const {
key,
capacity,
initial = [],
eq = Object.is,
toJSON = (x: T) => x as unknown as J,
fromJSON = (x: J) => x as unknown as T,
storage = typeof window !== "undefined" ? window.localStorage : undefined,
syncTabs = true,
} = opts;
if (capacity < 1) {
// Fail fast in dev; silently coerce in prod
if (process.env.NODE_ENV !== "production") {
throw new Error("useLRUList: capacity must be >= 1");
}
}
const effectiveCapacity = Math.max(1, capacity);
// Guard against SSR or no-storage environments
const canPersist = !!storage && typeof storage.getItem === "function";
const readPersisted = useCallback((): T[] | null => {
if (!canPersist) {
return null;
}
try {
const raw = storage.getItem(key);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PersistedPayload<J>;
if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.items)) {
return null;
}
const deserialized = parsed.items.map(fromJSON);
return trimToCapacity(deserialized, effectiveCapacity);
} catch {
return null;
}
}, [canPersist, storage, key, fromJSON, effectiveCapacity]);
const writePersisted = useCallback(
(items: T[]) => {
if (!canPersist) {
return;
}
try {
const payload: PersistedPayload<J> = {
v: 1,
capacity: effectiveCapacity,
items: items.map(toJSON),
};
storage.setItem(key, JSON.stringify(payload));
} catch {
// Swallow quota/serialization errors; keep in-memory state working
}
},
[canPersist, storage, key, toJSON, effectiveCapacity],
);
// Initialize from storage (or fallback to `initial`)
const [items, setItems] = useState<T[]>(
() => readPersisted() ?? trimToCapacity([...initial], effectiveCapacity),
);
// Keep a ref to avoid feedback loops when applying remote (storage event) updates
const applyingExternal = useRef(false);
// Persist on changes
useEffect(() => {
if (applyingExternal.current) {
applyingExternal.current = false;
return;
}
writePersisted(items);
}, [items, writePersisted]);
// Cross-tab synchronization via storage events
useEffect(() => {
if (!syncTabs || !canPersist || typeof window === "undefined") {
return;
}
const onStorage = (e: StorageEvent) => {
if (e.storageArea !== storage || e.key !== key) {
return;
}
// Another tab changed it; re-read safely
const next = readPersisted();
if (!next) {
return;
}
applyingExternal.current = true;
setItems(next);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [syncTabs, canPersist, storage, key, readPersisted]);
// Helpers
const indexOf = useCallback(
(arr: T[], needle: T) => arr.findIndex((x) => eq(x, needle)),
[eq],
);
const add = useCallback(
(item: T) => {
setItems((prev) => {
const idx = indexOf(prev, item);
if (idx === 0) {
return prev; // already MRU
}
if (idx > 0) {
const next = [...prev];
next.splice(idx, 1);
next.unshift(item);
return next;
}
// Not present: insert at MRU and trim
const next = [item, ...prev];
return trimToCapacity(next, effectiveCapacity);
});
},
[indexOf, effectiveCapacity],
);
const remove = useCallback(
(item: T) => {
setItems((prev) => {
const idx = indexOf(prev, item);
if (idx === -1) {
return prev;
}
const next = [...prev];
next.splice(idx, 1);
return next;
});
},
[indexOf],
);
const clear = useCallback(() => setItems([]), []);
const has = useCallback(
(item: T) => indexOf(items, item) !== -1,
[items, indexOf],
);
const replaceAll = useCallback(
(next: T[]) => setItems(trimToCapacity([...next], effectiveCapacity)),
[effectiveCapacity],
);
// Stable API shape
return useMemo(
() => ({
items,
add,
remove,
clear,
has,
replaceAll,
capacity: effectiveCapacity,
}),
[items, add, remove, clear, has, replaceAll, effectiveCapacity],
);
}
// --- utils ---
function trimToCapacity<T>(arr: T[], capacity: number): T[] {
if (arr.length <= capacity) {
return arr;
}
return arr.slice(0, capacity);
}
@@ -7,7 +7,6 @@ if (isDev) {
featureFlags.setOverrides({
persistNodeDB: true,
persistMessages: true,
persistDevices: false,
persistApp: true,
});
}
@@ -29,7 +29,7 @@ export type ValidModuleConfigType =
export type ConfigChangeKey =
| { type: "config"; variant: ValidConfigType }
| { type: "moduleConfig"; variant: ValidModuleConfigType }
| { type: "channel"; index: Types.ChannelNumber }
| { type: "channels"; index: Types.ChannelNumber }
| { type: "user" };
// Serialized key for Map storage
@@ -57,7 +57,7 @@ export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
return `config:${key.variant}`;
case "moduleConfig":
return `moduleConfig:${key.variant}`;
case "channel":
case "channels":
return `channel:${key.index}`;
case "user":
return "user";
@@ -78,9 +78,9 @@ export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
type: "moduleConfig",
variant: variant as ValidModuleConfigType,
};
case "channel":
case "channels":
return {
type: "channel",
type: "channels",
index: Number(variant) as Types.ChannelNumber,
};
case "user":
@@ -126,7 +126,7 @@ export function hasChannelChange(
registry: ChangeRegistry,
index: Types.ChannelNumber,
): boolean {
return registry.changes.has(serializeKey({ type: "channel", index }));
return registry.changes.has(serializeKey({ type: "channels", index }));
}
/**
@@ -171,7 +171,7 @@ export function getChannelChangeCount(registry: ChangeRegistry): number {
let count = 0;
for (const keyStr of registry.changes.keys()) {
const key = deserializeKey(keyStr);
if (key.type === "channel") {
if (key.type === "channels") {
count++;
}
}
@@ -212,7 +212,7 @@ export function getAllModuleConfigChanges(
export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
const changes: ChangeEntry[] = [];
for (const entry of registry.changes.values()) {
if (entry.key.type === "channel") {
if (entry.key.type === "channels") {
changes.push(entry);
}
}
@@ -1,5 +1,4 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { featureFlags } from "@core/services/featureFlags";
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
import { createStorage } from "@core/stores/utils/indexDB.ts";
import { type MeshDevice, Protobuf, Types } from "@meshtastic/core";
@@ -26,6 +25,8 @@ import {
serializeKey,
} from "./changeRegistry.ts";
import type {
Connection,
ConnectionId,
Dialogs,
DialogVariant,
ValidConfigType,
@@ -142,6 +143,16 @@ export interface deviceState {
removeDevice: (id: number) => void;
getDevices: () => Device[];
getDevice: (id: number) => Device | undefined;
// Saved connections management
savedConnections: Connection[];
addSavedConnection: (connection: Connection) => void;
updateSavedConnection: (
id: ConnectionId,
updates: Partial<Connection>,
) => void;
removeSavedConnection: (id: ConnectionId) => void;
getSavedConnections: () => Connection[];
}
interface PrivateDeviceState extends deviceState {
@@ -150,6 +161,7 @@ interface PrivateDeviceState extends deviceState {
type DevicePersisted = {
devices: Map<number, DeviceData>;
savedConnections: Connection[];
};
function deviceFactory(
@@ -894,6 +906,7 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
get,
) => ({
devices: new Map(),
savedConnections: [],
addDevice: (id) => {
const existing = get().devices.get(id);
@@ -924,6 +937,41 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
},
getDevices: () => Array.from(get().devices.values()),
getDevice: (id) => get().devices.get(id),
addSavedConnection: (connection) => {
set(
produce<PrivateDeviceState>((draft) => {
draft.savedConnections.push(connection);
}),
);
},
updateSavedConnection: (id, updates) => {
set(
produce<PrivateDeviceState>((draft) => {
const conn = draft.savedConnections.find(
(c: Connection) => c.id === id,
);
if (conn) {
for (const key in updates) {
if (Object.hasOwn(updates, key)) {
(conn as Record<string, unknown>)[key] =
updates[key as keyof typeof updates];
}
}
}
}),
);
},
removeSavedConnection: (id) => {
set(
produce<PrivateDeviceState>((draft) => {
draft.savedConnections = draft.savedConnections.filter(
(c: Connection) => c.id !== id,
);
}),
);
},
getSavedConnections: () => get().savedConnections,
});
const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
@@ -943,6 +991,7 @@ const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
},
]),
),
savedConnections: s.savedConnections,
}),
onRehydrateStorage: () => (state) => {
if (!state) {
@@ -980,14 +1029,6 @@ const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
},
};
// Add persist middleware on the store if the feature flag is enabled
const persistDevices = featureFlags.get("persistDevices");
console.debug(
`DeviceStore: Persisting devices is ${persistDevices ? "enabled" : "disabled"}`,
export const useDeviceStore = createStore(
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
);
export const useDeviceStore = persistDevices
? createStore(
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
)
: createStore(subscribeWithSelector(deviceStoreInitializer));
@@ -28,6 +28,46 @@ type DialogVariant = keyof Dialogs;
type Page = "messages" | "map" | "settings" | "channels" | "nodes";
export type ConnectionId = number;
export type ConnectionType = "http" | "bluetooth" | "serial";
export type ConnectionStatus =
| "connected"
| "connecting"
| "disconnected"
| "disconnecting"
| "configuring"
| "configured"
| "online"
| "error";
export type Connection = {
id: ConnectionId;
type: ConnectionType;
name: string;
createdAt: number;
lastConnectedAt?: number;
isDefault?: boolean;
status: ConnectionStatus;
error?: string;
meshDeviceId?: number;
} & NewConnection;
export type NewConnection =
| { type: "http"; name: string; url: string }
| {
type: "bluetooth";
name: string;
deviceId?: string;
deviceName?: string;
gattServiceUUID?: string;
}
| {
type: "serial";
name: string;
usbVendorId?: number;
usbProductId?: number;
};
type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
metadata: {
channel: number; // Channel on which the waypoint was received
+1 -1
View File
@@ -50,11 +50,11 @@ i18next
debug: import.meta.env.MODE === "development",
ns: [
"channels",
"connections",
"commandPalette",
"common",
"config",
"moduleConfig",
"dashboard",
"dialog",
"messages",
"nodes",
@@ -0,0 +1,411 @@
import AddConnectionDialog from "@app/components/Dialog/AddConnectionDialog/AddConnectionDialog";
import { ConnectionStatusBadge } from "@app/components/PageComponents/Connections/ConnectionStatusBadge";
import type { Connection } from "@app/core/stores/deviceStore/types";
import { useConnections } from "@app/pages/Connections/useConnections";
import {
connectionTypeIcon,
formatConnectionSubtext,
} from "@app/pages/Connections/utils";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@components/UI/AlertDialog.tsx";
import { Badge } from "@components/UI/Badge.tsx";
import { Button } from "@components/UI/Button.tsx";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@components/UI/Card.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@components/UI/DropdownMenu.tsx";
import { Separator } from "@components/UI/Separator.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useNavigate } from "@tanstack/react-router";
import {
ArrowLeft,
LinkIcon,
MoreHorizontal,
PlugZap,
RotateCw,
Star,
StarOff,
Trash2,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
export const Connections = () => {
const {
connections,
addConnectionAndConnect,
connect,
disconnect,
removeConnection,
setDefaultConnection,
refreshStatuses,
} = useConnections();
const { toast } = useToast();
const navigate = useNavigate({ from: "/" });
const [addOpen, setAddOpen] = useState(false);
const isURLHTTPS = useMemo(() => location.protocol === "https:", []);
const { t } = useTranslation("connections");
// On first mount, try to refresh statuses
// biome-ignore lint/correctness/useExhaustiveDependencies: This can cause the icon to refresh too often
useEffect(() => {
refreshStatuses();
}, []);
const sorted = useMemo(() => {
const copy = [...connections];
return copy.sort((a, b) => {
if (a.isDefault && !b.isDefault) {
return -1;
}
if (!a.isDefault && b.isDefault) {
return 1;
}
if (a.status === "connected" && b.status !== "connected") {
return -1;
}
return a.name.localeCompare(b.name);
});
}, [connections]);
return (
<div className="space-y-6 p-6">
<header className="flex items-start justify-between">
<div className="flex items-stretch gap-3">
<button
type="button"
onClick={() => navigate({ to: "/" })}
className="flex items-center justify-center w-9 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
{t("page.title")}
</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">
{t("page.description")}
</p>
</div>
</div>
<div className="flex items-center ml-2 gap-2">
<Button onClick={() => setAddOpen(true)} className="gap-2">
<PlugZap className="size-4" />
{t("button.addConnection")}
</Button>
</div>
</header>
<Separator />
{sorted.length === 0 ? (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-lg">
{t("noConnections.title")}{" "}
</CardTitle>
</CardHeader>
<CardContent className="text-slate-500 dark:text-slate-400">
{t("noConnections.description")}
</CardContent>
<CardFooter>
<Button onClick={() => setAddOpen(true)} className="gap-2">
<PlugZap className="size-4" />
{t("button.addConnection")}
</Button>
</CardFooter>
</Card>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sorted.map((c) => (
<ConnectionCard
key={c.id}
connection={c}
onConnect={async () => {
const ok = await connect(c.id, { allowPrompt: true });
toast({
title: ok ? t("toasts.connected") : t("toasts.failed"),
description: ok
? t("toasts.nowConnected", {
name: c.name,
interpolation: { escapeValue: false },
})
: t("toasts.checkConnetion"),
});
if (ok) {
navigate({ to: "/" });
}
}}
onDisconnect={async () => {
await disconnect(c.id);
toast({
title: t("toasts.disconnected"),
description: t("toasts.nowDisconnected", {
name: c.name,
interpolation: { escapeValue: false },
}),
});
}}
onSetDefault={() => {
setDefaultConnection(c.id);
toast({
title: t("toasts.defaultSet"),
description: t("toasts.defaultConnection", {
name: c.name,
interpolation: { escapeValue: false },
}),
});
}}
onDelete={async () => {
await disconnect(c.id);
removeConnection(c.id);
toast({
title: t("toasts.deleted"),
description: t("toasts.deletedByName", {
name: c.name,
interpolation: { escapeValue: false },
}),
});
}}
onRetry={async () => {
const ok = await connect(c.id, { allowPrompt: true });
toast({
title: ok ? t("toasts.connected") : t("toasts.failed"),
description: ok
? t("toasts.nowConnected", {
name: c.name,
interpolation: { escapeValue: false },
})
: t("toasts.pickConnectionAgain"),
});
if (ok) {
navigate({ to: "/" });
}
}}
/>
))}
</div>
)}
<AddConnectionDialog
open={addOpen}
onOpenChange={setAddOpen}
isHTTPS={isURLHTTPS}
onSave={async (partial, btDevice) => {
const created = await addConnectionAndConnect(partial, btDevice);
if (created) {
setAddOpen(false);
toast({
title: t("toasts.added"),
description: t("toasts.savedByName", {
name: created.name,
interpolation: { escapeValue: false },
}),
});
if (created.status === "connected") {
navigate({ to: "/" });
}
} else {
toast({
title: "Unable to connect",
description: "savedCantConnect",
});
}
}}
/>
</div>
);
};
function TypeBadge({ type }: { type: Connection["type"] }) {
const Icon = connectionTypeIcon(type);
const label =
type === "http" ? "HTTP" : type === "bluetooth" ? "Bluetooth" : "Serial";
return (
<Badge variant="default" className="gap-1.5">
<Icon className="h-3.5 w-3.5" />
{label}
</Badge>
);
}
function ConnectionCard({
connection,
onConnect,
onDisconnect,
onSetDefault,
onDelete,
onRetry,
}: {
connection: Connection;
onConnect: () => Promise<boolean> | Promise<void>;
onDisconnect: () => Promise<void> | Promise<void>;
onSetDefault: () => void;
onDelete: () => void;
onRetry: () => Promise<boolean> | Promise<void>;
}) {
const { t } = useTranslation("connections");
const Icon = connectionTypeIcon(connection.type);
const isBusy = connection.status === "connecting";
const isConnected = connection.status === "connected";
const isError = connection.status === "error";
return (
<Card className="flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="flex items-center gap-2">
<Icon className="size-4 text-slate-500 dark:text-slate-400" />
<span className="truncate">{connection.name}</span>
{connection.isDefault ? (
<Badge variant="secondary" className="gap-1">
<Star className="h-3.5 w-3.5 fill-yellow-500 text-yellow-500" />
{t("default")}
</Badge>
) : null}
</CardTitle>
<div className="mt-3 flex flex-wrap items-center gap-2 text-sm">
<TypeBadge type={connection.type} />
<span className="text-slate-500 dark:text-slate-400 truncate">
{formatConnectionSubtext(connection)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<ConnectionStatusBadge status={connection.status} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="size-4" />
<span className="sr-only">{t("moreActions")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{connection.type === "http" && connection.isDefault && (
<DropdownMenuItem
className="gap-2"
onClick={() => onSetDefault()}
>
<StarOff className="size-4" />
{t("button.unsetDefault")}
</DropdownMenuItem>
)}
{connection.type === "http" && !connection.isDefault && (
<DropdownMenuItem
className="gap-2"
onClick={() => onSetDefault()}
>
<Star className="size-4" />
{t("button.setDefault")}
</DropdownMenuItem>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="gap-2 text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
onSelect={(e) => e.preventDefault()}
>
<Trash2 className="size-4" />
{t("button.delete")}
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("deleteConnection")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSure", { name: connection.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={() => onDelete()}
>
{t("button.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{connection.error ? (
<p className="text-sm text-red-600 dark:text-red-400">
{connection.error}
</p>
) : connection.lastConnectedAt ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{t("lastConnectedAt", {
date: new Date(connection.lastConnectedAt),
})}
:{new Date(connection.lastConnectedAt).toLocaleString()}
</p>
) : (
<p className="text-sm text-slate-500 dark:text-slate-400">
{t("neverConnected")}
</p>
)}
</CardContent>
<CardFooter className="flex items-center gap-2 mt-auto">
{isConnected ? (
<Button
variant="subtle"
className="gap-2"
onClick={() => onDisconnect()}
disabled={isBusy}
>
{t("button.disconnect")}
</Button>
) : (
<Button
className="gap-2"
onClick={() => (isError ? onRetry() : onConnect())}
disabled={isBusy}
>
{isError ? (
<>
<RotateCw className="size-4" />
{t("button.retry")}
</>
) : (
<>
<LinkIcon className="size-4" />
{t("button.connect")}
</>
)}
</Button>
)}
</CardFooter>
</Card>
);
}
@@ -0,0 +1,496 @@
import type {
Connection,
ConnectionId,
ConnectionStatus,
NewConnection,
} from "@app/core/stores/deviceStore/types";
import {
createConnectionFromInput,
testHttpReachable,
} from "@app/pages/Connections/utils";
import {
useAppStore,
useDeviceStore,
useMessageStore,
useNodeDBStore,
} from "@core/stores";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useRef } from "react";
type LiveRefs = {
bt: Map<ConnectionId, BluetoothDevice>;
serial: Map<ConnectionId, SerialPort>;
meshDevices: Map<ConnectionId, MeshDevice>;
};
export function useConnections() {
const connections = useDeviceStore((s) => s.savedConnections);
const addSavedConnection = useDeviceStore((s) => s.addSavedConnection);
const updateSavedConnection = useDeviceStore((s) => s.updateSavedConnection);
const removeSavedConnectionFromStore = useDeviceStore(
(s) => s.removeSavedConnection,
);
const live = useRef<LiveRefs>({
bt: new Map(),
serial: new Map(),
meshDevices: new Map(),
});
const { addDevice } = useDeviceStore();
const { addNodeDB } = useNodeDBStore();
const { addMessageStore } = useMessageStore();
const { setSelectedDevice } = useAppStore();
const updateStatus = useCallback(
(id: ConnectionId, status: ConnectionStatus, error?: string) => {
const updates: Partial<Connection> = {
status,
error: error || undefined,
...(status === "disconnected" ? { lastConnectedAt: Date.now() } : {}),
};
updateSavedConnection(id, updates);
},
[updateSavedConnection],
);
const removeConnection = useCallback(
(id: ConnectionId) => {
// Disconnect MeshDevice first
const meshDevice = live.current.meshDevices.get(id);
if (meshDevice) {
try {
meshDevice.disconnect();
} catch {}
live.current.meshDevices.delete(id);
}
// Close live refs if open
const bt = live.current.bt.get(id);
if (bt?.gatt?.connected) {
try {
bt.gatt.disconnect();
} catch {
// Ignore errors
}
}
const sp = live.current.serial.get(id);
if (sp && "close" in sp) {
try {
(sp as SerialPort & { close: () => Promise<void> }).close();
} catch {
// Ignore errors
}
}
live.current.bt.delete(id);
live.current.serial.delete(id);
removeSavedConnectionFromStore(id);
},
[removeSavedConnectionFromStore],
);
const setDefaultConnection = useCallback(
(id: ConnectionId) => {
for (const connection of connections) {
if (connection.id === id) {
updateSavedConnection(connection.id, {
isDefault: !connection.isDefault,
});
}
}
},
[connections, updateSavedConnection],
);
const setupMeshDevice = useCallback(
(
id: ConnectionId,
transport:
| Awaited<ReturnType<typeof TransportHTTP.create>>
| Awaited<ReturnType<typeof TransportWebBluetooth.createFromDevice>>
| Awaited<ReturnType<typeof TransportWebSerial.createFromPort>>,
options?: {
setHeartbeat?: boolean;
onDisconnect?: () => void;
},
): number => {
const deviceId = randId();
const device = addDevice(deviceId);
const nodeDB = addNodeDB(deviceId);
const messageStore = addMessageStore(deviceId);
const meshDevice = new MeshDevice(transport, deviceId);
meshDevice.configure();
setSelectedDevice(deviceId);
device.addConnection(meshDevice);
subscribeAll(device, meshDevice, messageStore, nodeDB);
live.current.meshDevices.set(id, meshDevice);
if (options?.setHeartbeat) {
const HEARTBEAT_INTERVAL = 5 * 60 * 1000;
meshDevice.setHeartbeatInterval(HEARTBEAT_INTERVAL);
}
updateSavedConnection(id, { meshDeviceId: deviceId });
return deviceId;
},
[
addDevice,
addNodeDB,
addMessageStore,
setSelectedDevice,
updateSavedConnection,
],
);
const connect = useCallback(
async (id: ConnectionId, opts?: { allowPrompt?: boolean }) => {
const conn = connections.find((c) => c.id === id);
if (!conn) {
return false;
}
if (conn.status === "connected") {
return true;
}
updateStatus(id, "connecting");
try {
if (conn.type === "http") {
const ok = await testHttpReachable(conn.url);
if (!ok) {
const url = new URL(conn.url);
const isHTTPS = url.protocol === "https:";
const message = isHTTPS
? `Cannot reach HTTPS endpoint. If using a self-signed certificate, open ${conn.url} in a new tab, accept the certificate warning, then try connecting again.`
: "HTTP endpoint not reachable (may be blocked by CORS)";
throw new Error(message);
}
const url = new URL(conn.url);
const isTLS = url.protocol === "https:";
const transport = await TransportHTTP.create(url.host, isTLS);
setupMeshDevice(id, transport);
updateStatus(id, "connected");
return true;
}
if (conn.type === "bluetooth") {
if (!("bluetooth" in navigator)) {
throw new Error("Web Bluetooth not supported");
}
let bleDevice = live.current.bt.get(id);
if (!bleDevice) {
// Try to recover permitted devices
const getDevices = (
navigator.bluetooth as Navigator["bluetooth"] & {
getDevices?: () => Promise<BluetoothDevice[]>;
}
).getDevices;
if (getDevices) {
const known = await getDevices();
if (known && known.length > 0 && conn.deviceId) {
bleDevice = known.find(
(d: BluetoothDevice) => d.id === conn.deviceId,
);
// If found, store it for future use
if (bleDevice) {
live.current.bt.set(id, bleDevice);
}
}
}
}
if (!bleDevice && opts?.allowPrompt) {
// Prompt user to reselect (filter by optional service if provided)
bleDevice = await navigator.bluetooth.requestDevice({
acceptAllDevices: !conn.gattServiceUUID,
optionalServices: conn.gattServiceUUID
? [conn.gattServiceUUID]
: undefined,
filters: conn.gattServiceUUID
? [{ services: [conn.gattServiceUUID] }]
: undefined,
});
}
if (!bleDevice) {
throw new Error(
"Bluetooth device not available. Re-select the device.",
);
}
live.current.bt.set(id, bleDevice);
const transport =
await TransportWebBluetooth.createFromDevice(bleDevice);
setupMeshDevice(id, transport, { setHeartbeat: true });
bleDevice.addEventListener("gattserverdisconnected", () => {
updateStatus(id, "disconnected");
});
updateStatus(id, "connected");
return true;
}
if (conn.type === "serial") {
if (!("serial" in navigator)) {
throw new Error("Web Serial not supported");
}
let port = live.current.serial.get(id);
if (!port) {
// Find a previously granted port by vendor/product
const ports: SerialPort[] = await (
navigator as Navigator & {
serial: { getPorts: () => Promise<SerialPort[]> };
}
).serial.getPorts();
if (ports && conn.usbVendorId && conn.usbProductId) {
port = ports.find((p: SerialPort) => {
const info =
(
p as SerialPort & {
getInfo?: () => {
usbVendorId?: number;
usbProductId?: number;
};
}
).getInfo?.() ?? {};
return (
info.usbVendorId === conn.usbVendorId &&
info.usbProductId === conn.usbProductId
);
});
}
}
if (!port && opts?.allowPrompt) {
port = await (
navigator as Navigator & {
serial: {
requestPort: (
options: Record<string, unknown>,
) => Promise<SerialPort>;
};
}
).serial.requestPort({});
}
if (!port) {
throw new Error("Serial port not available. Re-select the port.");
}
// Ensure the port is closed before opening it
const portWithStreams = port as SerialPort & {
readable: ReadableStream | null;
writable: WritableStream | null;
close: () => Promise<void>;
};
if (portWithStreams.readable || portWithStreams.writable) {
try {
await portWithStreams.close();
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (err) {
console.warn("Error closing port before reconnect:", err);
}
}
live.current.serial.set(id, port);
const transport = await TransportWebSerial.createFromPort(port);
setupMeshDevice(id, transport, { setHeartbeat: true });
updateStatus(id, "connected");
return true;
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
updateStatus(id, "error", message);
return false;
}
return false;
},
[connections, updateStatus, setupMeshDevice],
);
const disconnect = useCallback(
async (id: ConnectionId) => {
const conn = connections.find((c) => c.id === id);
if (!conn) {
return;
}
try {
// Disconnect MeshDevice first
const meshDevice = live.current.meshDevices.get(id);
if (meshDevice) {
try {
meshDevice.disconnect();
} catch {
// Ignore errors
}
live.current.meshDevices.delete(id);
}
if (conn.type === "bluetooth") {
const dev = live.current.bt.get(id);
if (dev?.gatt?.connected) {
dev.gatt.disconnect();
}
}
if (conn.type === "serial") {
const port = live.current.serial.get(id);
if (port) {
try {
const portWithClose = port as SerialPort & {
close: () => Promise<void>;
readable: ReadableStream | null;
};
// Only close if the port is open (has readable stream)
if (portWithClose.readable) {
await portWithClose.close();
}
} catch (err) {
console.warn("Error closing serial port:", err);
}
live.current.serial.delete(id);
}
}
} finally {
updateSavedConnection(id, {
status: "disconnected",
error: undefined,
});
}
},
[connections, updateSavedConnection],
);
const addConnection = useCallback(
(input: NewConnection) => {
const conn = createConnectionFromInput(input);
addSavedConnection(conn);
return conn;
},
[addSavedConnection],
);
const addConnectionAndConnect = useCallback(
async (input: NewConnection, btDevice?: BluetoothDevice) => {
const conn = addConnection(input);
// If a Bluetooth device was provided, store it to avoid re-prompting
if (btDevice && conn.type === "bluetooth") {
live.current.bt.set(conn.id, btDevice);
}
await connect(conn.id, { allowPrompt: true });
// Get updated connection from store after connect
if (conn.id) {
return conn;
}
},
[addConnection, connect],
);
const refreshStatuses = useCallback(async () => {
// Check reachability/availability without auto-connecting
// HTTP: test endpoint reachability
// Bluetooth/Serial: check permission grants
// HTTP connections: test reachability if not already connected
const httpChecks = connections
.filter(
(c): c is Connection & { type: "http"; url: string } =>
c.type === "http" && c.status !== "connected",
)
.map(async (c) => {
const ok = await testHttpReachable(c.url);
updateSavedConnection(c.id, {
status: ok ? "online" : "error",
});
});
// Bluetooth connections: check permission grants
const btChecks = connections
.filter(
(c): c is Connection & { type: "bluetooth"; deviceId?: string } =>
c.type === "bluetooth" && c.status !== "connected",
)
.map(async (c) => {
if (!("bluetooth" in navigator)) {
return;
}
try {
const known = await (
navigator.bluetooth as Navigator["bluetooth"] & {
getDevices?: () => Promise<BluetoothDevice[]>;
}
).getDevices?.();
const hasPermission = known?.some(
(d: BluetoothDevice) => d.id === c.deviceId,
);
updateSavedConnection(c.id, {
status: hasPermission ? "configured" : "disconnected",
});
} catch {
// getDevices not supported or failed
updateSavedConnection(c.id, { status: "disconnected" });
}
});
// Serial connections: check permission grants
const serialChecks = connections
.filter(
(
c,
): c is Connection & {
type: "serial";
usbVendorId?: number;
usbProductId?: number;
} => c.type === "serial" && c.status !== "connected",
)
.map(async (c) => {
if (!("serial" in navigator)) {
return;
}
try {
const ports: SerialPort[] = await (
navigator as Navigator & {
serial: { getPorts: () => Promise<SerialPort[]> };
}
).serial.getPorts();
const hasPermission = ports.some((p: SerialPort) => {
const info =
(
p as SerialPort & {
getInfo?: () => {
usbVendorId?: number;
usbProductId?: number;
};
}
).getInfo?.() ?? {};
return (
info.usbVendorId === c.usbVendorId &&
info.usbProductId === c.usbProductId
);
});
updateSavedConnection(c.id, {
status: hasPermission ? "configured" : "disconnected",
});
} catch {
// getPorts failed
updateSavedConnection(c.id, { status: "disconnected" });
}
});
await Promise.all([...httpChecks, ...btChecks, ...serialChecks]);
}, [connections, updateSavedConnection]);
return {
connections,
addConnection,
addConnectionAndConnect,
connect,
disconnect,
removeConnection,
setDefaultConnection,
refreshStatuses,
};
}
@@ -0,0 +1,84 @@
import type {
Connection,
ConnectionStatus,
ConnectionType,
NewConnection,
} from "@app/core/stores/deviceStore/types";
import { randId } from "@app/core/utils/randId";
import { Bluetooth, Cable, Globe, type LucideIcon } from "lucide-react";
export function createConnectionFromInput(input: NewConnection): Connection {
const base = {
id: randId(),
name: input.name,
createdAt: Date.now(),
status: "disconnected" as ConnectionStatus,
};
if (input.type === "http") {
return {
...base,
type: "http",
url: input.url,
isDefault: false,
name: input.name.length === 0 ? input.url : input.name,
};
}
if (input.type === "bluetooth") {
return {
...base,
type: "bluetooth",
deviceId: input.deviceId,
deviceName: input.deviceName,
gattServiceUUID: input.gattServiceUUID,
};
}
return {
...base,
type: "serial",
usbVendorId: input.usbVendorId,
usbProductId: input.usbProductId,
};
}
export async function testHttpReachable(
url: string,
timeoutMs = 2500,
): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// Use no-cors to avoid CORS failure; opaque responses resolve but status is 0
await fetch(url, {
method: "GET",
mode: "no-cors",
cache: "no-store",
signal: controller.signal,
});
clearTimeout(timer);
return true;
} catch {
return false;
}
}
export function connectionTypeIcon(type: ConnectionType): LucideIcon {
if (type === "http") {
return Globe;
}
if (type === "bluetooth") {
return Bluetooth;
}
return Cable;
}
export function formatConnectionSubtext(conn: Connection): string {
if (conn.type === "http") {
return conn.url;
}
if (conn.type === "bluetooth") {
return conn.deviceName || conn.deviceId || "No device selected";
}
const v = conn.usbVendorId ? conn.usbVendorId.toString(16) : "?";
const p = conn.usbProductId ? conn.usbProductId.toString(16) : "?";
return `USB ${v}:${p}`;
}
@@ -1,93 +0,0 @@
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Separator } from "@components/UI/Separator.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import {
useAppStore /*, useDeviceStore, useNodeDBStore */,
} from "@core/stores";
import { ListPlusIcon, PlusIcon /*, UsersIcon */ } from "lucide-react";
/* import { useMemo } from "react"; */
import { useTranslation } from "react-i18next";
export const Dashboard = () => {
const { t } = useTranslation("dashboard");
const { setConnectDialogOpen /*, setSelectedDevice*/ } = useAppStore();
/*
const { getDevices } = useDeviceStore();
const { getNodeDB } = useNodeDBStore();
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) => {
const nodeDB = getNodeDB(device.id);
if (!nodeDB) {
return undefined;
}
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">
{nodeDB.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"
/>
{nodeDB.getNodesLength() === 0
? 0
: nodeDB.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>
);
};
+2 -2
View File
@@ -40,7 +40,7 @@ export interface DeleteNoteDialogProps {
const NodesPage = (): JSX.Element => {
const { t } = useTranslation("nodes");
const { currentLanguage } = useLang();
const { current } = useLang();
const { hardware, connection, setDialogOpen } = useDevice();
const { setNodeNumDetails } = useAppStore();
@@ -200,7 +200,7 @@ const NodesPage = (): JSX.Element => {
) : (
<TimeAgo
timestamp={node.lastHeard * 1000}
locale={currentLanguage?.code}
locale={current?.code}
/>
)}
</Mono>
+9 -2
View File
@@ -1,6 +1,6 @@
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import type { useAppStore, useMessageStore } from "@core/stores";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import { Connections } from "@pages/Connections/index.tsx";
import MapPage from "@pages/Map/index.tsx";
import MessagesPage from "@pages/Messages.tsx";
import NodesPage from "@pages/Nodes/index.tsx";
@@ -30,7 +30,7 @@ export const rootRoute = createRootRouteWithContext<AppContext>()({
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: Dashboard,
component: Connections,
loader: () => {
// Redirect to the broadcast messages page on initial load
return redirect({ to: "/messages/broadcast/0", replace: true });
@@ -151,6 +151,12 @@ const dialogWithParamsRoute = createRoute({
component: DialogManager,
});
const connectionsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/connections",
component: Connections,
});
const routeTree = rootRoute.addChildren([
indexRoute,
messagesRoute,
@@ -160,6 +166,7 @@ const routeTree = rootRoute.addChildren([
settingsRoute.addChildren([radioRoute, deviceRoute, moduleRoute]),
nodesRoute,
dialogWithParamsRoute,
connectionsRoute,
]);
const router = createRouter({
+3 -3
View File
@@ -15,7 +15,7 @@ import commonEN from "@public/i18n/locales/en/common.json" with {
import configEN from "@public/i18n/locales/en/config.json" with {
type: "json",
};
import dashboardEN from "@public/i18n/locales/en/dashboard.json" with {
import connectionsEN from "@public/i18n/locales/en/connections.json" with {
type: "json",
};
import dialogEN from "@public/i18n/locales/en/dialog.json" with {
@@ -55,7 +55,7 @@ const appNamespaces = [
"common",
"config",
"moduleConfig",
"dashboard",
"connections",
"dialog",
"messages",
"nodes",
@@ -78,7 +78,7 @@ i18n.use(initReactI18next).init({
common: commonEN,
config: configEN,
moduleConfig: moduleConfigEN,
dashboard: dashboardEN,
connections: connectionsEN,
dialog: dialogEN,
messages: messagesEN,
nodes: nodesEN,
+4 -4
View File
@@ -4,12 +4,12 @@ import { z } from "zod/v4";
export const UserValidationSchema = z.object({
longName: z
.string()
.min(1, t("deviceName.validation.longNameMin"))
.max(40, t("deviceName.validation.longNameMax")),
.min(1, t("user.longName.validation.min"))
.max(40, t("user.longName.validation.max")),
shortName: z
.string()
.min(2, t("deviceName.validation.shortNameMin"))
.max(4, t("deviceName.validation.shortNameMax")),
.min(2, t("user.shortName.validation.min"))
.max(4, t("user.shortName.validation.max")),
isUnmessageable: z.boolean().default(false),
isLicensed: z.boolean().default(false),
});