Browse Source
* Remove deprecated meshtastic/js dependency * Bump dependency version * Fix linting --------- Co-authored-by: philon- <[email protected]>pull/645/head
committed by
“tpaulsoncdw”
36 changed files with 17270 additions and 277 deletions
@ -5,10 +5,6 @@ |
|||
"jsr:@std/path@^1.1.0": "1.1.0", |
|||
"npm:@bufbuild/protobuf@^2.2.5": "2.2.5", |
|||
"npm:@jsr/[email protected]": "2.6.2", |
|||
"npm:@jsr/[email protected]": "2.6.0-0", |
|||
"npm:@jsr/meshtastic__transport-http@*": "0.2.1", |
|||
"npm:@jsr/meshtastic__transport-web-bluetooth@*": "0.1.1", |
|||
"npm:@jsr/meshtastic__transport-web-serial@*": "0.2.1", |
|||
"npm:@noble/curves@^1.9.0": "1.9.0", |
|||
"npm:@radix-ui/react-accordion@^1.2.8": "1.2.8_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]", |
|||
"npm:@radix-ui/react-checkbox@^1.2.3": "1.2.3_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]", |
|||
@ -46,7 +42,6 @@ |
|||
"npm:@vitejs/plugin-react@^4.4.1": "[email protected]__@[email protected][email protected]_@[email protected]_@[email protected]", |
|||
"npm:autoprefixer@^10.4.21": "[email protected]", |
|||
"npm:base64-js@^1.5.1": "1.5.1", |
|||
"npm:class-validator@~0.14.2": "0.14.2", |
|||
"npm:class-variance-authority@~0.7.1": "0.7.1", |
|||
"npm:clsx@^2.1.1": "2.1.1", |
|||
"npm:cmdk@^1.1.1": "[email protected][email protected][email protected]_@[email protected]_@[email protected]__@[email protected]", |
|||
@ -1131,17 +1126,6 @@ |
|||
], |
|||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.2.tgz" |
|||
}, |
|||
"@jsr/[email protected]": { |
|||
"integrity": "sha512-+xpZpxK6oUIVOuEs7C+LyxRr2druvc7UNNNTK9Rl8ioXj63Jz1uQXlYe2Gj0xjnRAiSQLR7QVaPef21BR/YTxA==", |
|||
"dependencies": [ |
|||
"@bufbuild/protobuf", |
|||
"@jsr/meshtastic__protobufs", |
|||
"crc", |
|||
"ste-simple-events", |
|||
"tslog" |
|||
], |
|||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__js/2.6.0-0.tgz" |
|||
}, |
|||
"@jsr/[email protected]": { |
|||
"integrity": "sha512-bIENtFnUEru28GrAeSdiBS9skp0hN/3HZunMbF/IjvUrXOlx2fptKVj3b+pzjOWnLBZxllrByV/W+XDmrxqJ6g==", |
|||
"dependencies": [ |
|||
@ -1149,27 +1133,6 @@ |
|||
], |
|||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__protobufs/2.6.2.tgz" |
|||
}, |
|||
"@jsr/[email protected]": { |
|||
"integrity": "sha512-lmQKr3aIINKvtGROU4HchmSVqbZSbkIHqajowRRC8IAjsnR0zNTyxz210QyY4pFUF9hpcW3GRjwq5h/VO2JuGg==", |
|||
"dependencies": [ |
|||
"@jsr/meshtastic__core" |
|||
], |
|||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-http/0.2.1.tgz" |
|||
}, |
|||
"@jsr/[email protected]": { |
|||
"integrity": "sha512-eAj23n/Pxe8hMjO/uYbI/C+l1s0tLm41EzvcLWQtLQyEKJpPP+/Eqc5lUmDeF7FVPS2IYhllFJvV8Ili7okHtQ==", |
|||
"dependencies": [ |
|||
"@jsr/meshtastic__core" |
|||
], |
|||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-bluetooth/0.1.1.tgz" |
|||
}, |
|||
"@jsr/[email protected]": { |
|||
"integrity": "sha512-yumjEGLkAuJYOC3aWKvZzbQqi/LnqaKfNpVCY7Ki7oLtAshNiZrBLiwiFhN7+ZR9FfMdJThyBMqREBDRRWTO1Q==", |
|||
"dependencies": [ |
|||
"@jsr/meshtastic__core" |
|||
], |
|||
"tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-serial/0.2.1.tgz" |
|||
}, |
|||
"@mapbox/[email protected]": { |
|||
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", |
|||
"dependencies": [ |
|||
@ -4000,9 +3963,6 @@ |
|||
"@types/[email protected]": { |
|||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" |
|||
}, |
|||
"@types/[email protected]": { |
|||
"integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA==" |
|||
}, |
|||
"@types/[email protected]": { |
|||
"integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==" |
|||
}, |
|||
@ -4485,14 +4445,6 @@ |
|||
"[email protected]" |
|||
] |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", |
|||
"dependencies": [ |
|||
"@types/validator", |
|||
"libphonenumber-js", |
|||
"validator" |
|||
] |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", |
|||
"dependencies": [ |
|||
@ -5639,9 +5591,6 @@ |
|||
"[email protected]": { |
|||
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-0nYZSNj/QEikyhcM5RZFXGlCB/mr4PVamnT1C2sKBnDDTYndrvbybYjvg+PMqAndQHlLbwQ3socolnL3WWTUFA==" |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", |
|||
"os": ["darwin"], |
|||
@ -7174,9 +7123,6 @@ |
|||
"which-typed-array" |
|||
] |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==" |
|||
}, |
|||
"[email protected]_@[email protected]": { |
|||
"integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", |
|||
"dependencies": [ |
|||
@ -7581,10 +7527,9 @@ |
|||
"dependencies": [ |
|||
"npm:@bufbuild/protobuf@^2.2.5", |
|||
"npm:@jsr/[email protected]", |
|||
"npm:@jsr/[email protected]", |
|||
"npm:@jsr/meshtastic__transport-http@*", |
|||
"npm:@jsr/meshtastic__transport-web-bluetooth@*", |
|||
"npm:@jsr/meshtastic__transport-web-serial@*", |
|||
"npm:@jsr/[email protected]", |
|||
"npm:@jsr/[email protected]", |
|||
"npm:@jsr/[email protected]", |
|||
"npm:@noble/curves@^1.9.0", |
|||
"npm:@radix-ui/react-accordion@^1.2.8", |
|||
"npm:@radix-ui/react-checkbox@^1.2.3", |
|||
@ -7622,11 +7567,11 @@ |
|||
"npm:@vitejs/plugin-react@^4.4.1", |
|||
"npm:autoprefixer@^10.4.21", |
|||
"npm:base64-js@^1.5.1", |
|||
"npm:class-validator@~0.14.2", |
|||
"npm:class-variance-authority@~0.7.1", |
|||
"npm:clsx@^2.1.1", |
|||
"npm:cmdk@^1.1.1", |
|||
"npm:crypto-random-string@5", |
|||
"npm:deno@^2.3.5", |
|||
"npm:gzipper@^8.2.1", |
|||
"npm:happy-dom@^17.4.6", |
|||
"npm:i18next-browser-languagedetector@^8.1.0", |
|||
@ -7659,7 +7604,7 @@ |
|||
"npm:vite-plugin-static-copy@3", |
|||
"npm:vite@^6.3.4", |
|||
"npm:vitest@^3.1.2", |
|||
"npm:zod@^3.24.3", |
|||
"npm:zod@^3.25.0", |
|||
"npm:[email protected]" |
|||
] |
|||
} |
|||
|
|||
File diff suppressed because it is too large
@ -1,5 +1,5 @@ |
|||
{ |
|||
"name": "meshtastic-web", |
|||
"name": "@meshtastic/web", |
|||
"version": "2.6.0-0", |
|||
"type": "module", |
|||
"description": "Meshtastic web client", |
|||
@ -36,10 +36,9 @@ |
|||
"dependencies": { |
|||
"@bufbuild/protobuf": "^2.2.5", |
|||
"@meshtastic/core": "npm:@jsr/[email protected]", |
|||
"@meshtastic/js": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", |
|||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", |
|||
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", |
|||
"@meshtastic/transport-http": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-web-serial": "npm:@jsr/[email protected]", |
|||
"@noble/curves": "^1.9.0", |
|||
"@radix-ui/react-accordion": "^1.2.8", |
|||
"@radix-ui/react-checkbox": "^1.2.3", |
|||
@ -66,6 +65,7 @@ |
|||
"clsx": "^2.1.1", |
|||
"cmdk": "^1.1.1", |
|||
"crypto-random-string": "^5.0.0", |
|||
"deno": "^2.3.5", |
|||
"i18next": "^25.2.0", |
|||
"i18next-browser-languagedetector": "^8.1.0", |
|||
"i18next-http-backend": "^3.0.2", |
|||
|
|||
@ -1,5 +1,5 @@ |
|||
module.exports = { |
|||
plugins: { |
|||
'@tailwindcss/postcss': {}, |
|||
"@tailwindcss/postcss": {}, |
|||
}, |
|||
}; |
|||
|
|||
@ -0,0 +1,58 @@ |
|||
import { |
|||
Tabs, |
|||
TabsContent, |
|||
TabsList, |
|||
TabsTrigger, |
|||
} from "@components/UI/Tabs.tsx"; |
|||
import { HTTPTab } from "@components/Dialog/Tabs/HTTPTab.tsx"; |
|||
import { BluetoothTab } from "@components/Dialog/Tabs/BluetoothTab.tsx"; |
|||
import { SerialTab } from "@components/Dialog/Tabs/SerialTab.tsx"; |
|||
import { Bluetooth, Server, Usb } from "lucide-react"; |
|||
|
|||
interface ConnectionTabsProps { |
|||
closeDialog?: () => void; |
|||
className?: string; |
|||
} |
|||
|
|||
export const ConnectionTabs = ( |
|||
{ closeDialog, className }: ConnectionTabsProps, |
|||
) => { |
|||
const handleClose = () => { |
|||
if (closeDialog) { |
|||
closeDialog(); |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<Tabs defaultValue="http" className={`w-full ${className || ""}`}> |
|||
<TabsList className="grid w-full grid-cols-3 mb-4"> |
|||
<TabsTrigger value="http" className="flex items-center gap-2"> |
|||
<Server className="h-4 w-4" /> |
|||
HTTP |
|||
</TabsTrigger> |
|||
<TabsTrigger value="bluetooth" className="flex items-center gap-2"> |
|||
<Bluetooth className="h-4 w-4" /> |
|||
Bluetooth |
|||
</TabsTrigger> |
|||
<TabsTrigger value="serial" className="flex items-center gap-2"> |
|||
<Usb className="h-4 w-4" /> |
|||
Serial |
|||
</TabsTrigger> |
|||
</TabsList> |
|||
|
|||
<div className="min-h-[400px] max-h-[500px] overflow-y-auto"> |
|||
<TabsContent value="http" className="mt-0"> |
|||
<HTTPTab closeDialog={handleClose} /> |
|||
</TabsContent> |
|||
|
|||
<TabsContent value="bluetooth" className="mt-0"> |
|||
<BluetoothTab closeDialog={handleClose} /> |
|||
</TabsContent> |
|||
|
|||
<TabsContent value="serial" className="mt-0"> |
|||
<SerialTab closeDialog={handleClose} /> |
|||
</TabsContent> |
|||
</div> |
|||
</Tabs> |
|||
); |
|||
}; |
|||
@ -0,0 +1,224 @@ |
|||
import { useCallback, useEffect, useState } from "react"; |
|||
import { Button } from "@components/UI/Button.tsx"; |
|||
import { useAppStore } from "@core/stores/appStore.ts"; |
|||
import { useDeviceStore } from "@core/stores/deviceStore.ts"; |
|||
import { subscribeAll } from "@core/subscriptions.ts"; |
|||
import { randId } from "@core/utils/randId.ts"; |
|||
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; |
|||
import { MeshDevice } from "@meshtastic/core"; |
|||
import { |
|||
AlertTriangle, |
|||
Bluetooth, |
|||
Circle, |
|||
Clock, |
|||
Plus, |
|||
Trash2, |
|||
} from "lucide-react"; |
|||
import { useMessageStore } from "@core/stores/messageStore/index.ts"; |
|||
|
|||
interface BluetoothSectionProps { |
|||
onConnect?: () => void; |
|||
} |
|||
|
|||
export const BluetoothSection = ({ onConnect }: BluetoothSectionProps) => { |
|||
const [connectionInProgress, setConnectionInProgress] = useState(false); |
|||
const [connectingToDevice, setConnectingToDevice] = useState<string | null>( |
|||
null, |
|||
); |
|||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]); |
|||
const [connectionError, setConnectionError] = useState<string | null>(null); |
|||
|
|||
const { addDevice } = useDeviceStore(); |
|||
const messageStore = useMessageStore(); |
|||
const { setSelectedDevice } = useAppStore(); |
|||
|
|||
const updateBleDeviceList = useCallback(async (): Promise<void> => { |
|||
try { |
|||
setBleDevices(await navigator.bluetooth.getDevices()); |
|||
} catch (error) { |
|||
console.error("Error getting Bluetooth devices:", error); |
|||
} |
|||
}, []); |
|||
|
|||
useEffect(() => { |
|||
updateBleDeviceList(); |
|||
}, [updateBleDeviceList]); |
|||
|
|||
const connectToDevice = async (bleDevice: BluetoothDevice) => { |
|||
setConnectingToDevice(bleDevice.id); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const id = randId(); |
|||
const transport = await TransportWebBluetooth.createFromDevice(bleDevice); |
|||
const device = addDevice(id); |
|||
const connection = new MeshDevice(transport, id); |
|||
connection.configure(); |
|||
setSelectedDevice(id); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection, messageStore); |
|||
|
|||
onConnect?.(); |
|||
} catch (error) { |
|||
console.error("Bluetooth connection error:", error); |
|||
setConnectionError(`Failed to connect to ${bleDevice.name || "device"}`); |
|||
} finally { |
|||
setConnectingToDevice(null); |
|||
} |
|||
}; |
|||
|
|||
const handlePairNewDevice = async () => { |
|||
setConnectionInProgress(true); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const device = await navigator.bluetooth.requestDevice({ |
|||
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }], |
|||
}); |
|||
|
|||
const exists = bleDevices.findIndex((d) => d.id === device.id); |
|||
if (exists === -1) { |
|||
setBleDevices([...bleDevices, device]); |
|||
} |
|||
} catch (error) { |
|||
console.error("Error pairing device:", error); |
|||
if (error instanceof Error && !error.message.includes("cancelled")) { |
|||
setConnectionError("Failed to pair new device"); |
|||
} |
|||
} finally { |
|||
setConnectionInProgress(false); |
|||
} |
|||
}; |
|||
|
|||
const removeBleDevice = (deviceId: string) => { |
|||
setBleDevices(bleDevices.filter((d) => d.id !== deviceId)); |
|||
}; |
|||
|
|||
const getStatusIcon = (device: BluetoothDevice) => { |
|||
const isConnecting = connectingToDevice === device.id; |
|||
const isConnected = device.gatt?.connected; |
|||
|
|||
if (isConnecting) { |
|||
return ( |
|||
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" /> |
|||
); |
|||
} else if (isConnected) { |
|||
return <Circle className="h-3 w-3 fill-blue-500 text-blue-500" />; |
|||
} else { |
|||
return <Circle className="h-3 w-3 fill-slate-300 text-slate-300" />; |
|||
} |
|||
}; |
|||
|
|||
const getStatusText = (device: BluetoothDevice) => { |
|||
if (connectingToDevice === device.id) return "Connecting..."; |
|||
return device.gatt?.connected ? "Connected" : "Paired"; |
|||
}; |
|||
|
|||
return ( |
|||
<div className="space-y-3"> |
|||
{/* Header */} |
|||
<div className="flex items-center gap-2"> |
|||
<Bluetooth className="h-5 w-5 text-blue-600" /> |
|||
<h3 className="font-semibold text-slate-900 dark:text-slate-100"> |
|||
Bluetooth Devices |
|||
</h3> |
|||
</div> |
|||
|
|||
{/* Device List */} |
|||
<div className="space-y-2 bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4 border border-slate-200 dark:border-slate-700"> |
|||
{bleDevices.length === 0 |
|||
? ( |
|||
<div className="text-center py-4 text-slate-500 dark:text-slate-400"> |
|||
<Bluetooth className="h-8 w-8 mx-auto mb-2 text-slate-300" /> |
|||
<p className="text-sm">No Bluetooth devices paired</p> |
|||
</div> |
|||
) |
|||
: ( |
|||
bleDevices.map((device) => ( |
|||
<div |
|||
key={device.id} |
|||
className="flex items-center gap-3 p-3 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 hover:shadow-sm transition-shadow" |
|||
> |
|||
{/* Status */} |
|||
<div className="flex-shrink-0"> |
|||
{getStatusIcon(device)} |
|||
</div> |
|||
|
|||
{/* Device Info */} |
|||
<div className="flex-1 min-w-0"> |
|||
<div className="flex items-center gap-2"> |
|||
<span className="font-medium text-slate-900 dark:text-slate-100 truncate"> |
|||
{device.name || "Unknown Device"} |
|||
</span> |
|||
<Bluetooth className="h-4 w-4 text-blue-500" /> |
|||
</div> |
|||
|
|||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1"> |
|||
{getStatusText(device)} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Actions */} |
|||
<div className="flex items-center gap-1"> |
|||
<Button |
|||
size="sm" |
|||
onClick={() => connectToDevice(device)} |
|||
disabled={connectingToDevice === device.id || |
|||
device.gatt?.connected} |
|||
> |
|||
{connectingToDevice === device.id |
|||
? <Clock className="h-3 w-3 mr-1 animate-spin" /> |
|||
: <Bluetooth className="h-3 w-3 mr-1" />} |
|||
{device.gatt?.connected ? "Connected" : "Connect"} |
|||
</Button> |
|||
|
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => removeBleDevice(device.id)} |
|||
className="text-slate-400 hover:text-red-600 p-1" |
|||
> |
|||
<Trash2 className="h-3 w-3" /> |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
)) |
|||
)} |
|||
|
|||
{/* Pair New Device Button */} |
|||
<Button |
|||
variant="outline" |
|||
onClick={handlePairNewDevice} |
|||
disabled={connectionInProgress} |
|||
className="w-full border-dashed hover:border-solid" |
|||
> |
|||
{connectionInProgress |
|||
? ( |
|||
<> |
|||
<Clock className="h-4 w-4 mr-2 animate-spin" /> |
|||
Pairing... |
|||
</> |
|||
) |
|||
: ( |
|||
<> |
|||
<Plus className="h-4 w-4 mr-2" /> |
|||
Pair New Device |
|||
</> |
|||
)} |
|||
</Button> |
|||
|
|||
{/* Connection Error */} |
|||
{connectionError && ( |
|||
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800"> |
|||
<div className="flex gap-2 items-start"> |
|||
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" /> |
|||
<p className="text-sm text-red-800 dark:text-red-200"> |
|||
{connectionError} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -0,0 +1,410 @@ |
|||
import { useEffect, useState } from "react"; |
|||
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 { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog.tsx"; |
|||
import { useAppStore } from "@core/stores/appStore.ts"; |
|||
import { useDeviceStore } from "@core/stores/deviceStore.ts"; |
|||
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 { useForm } from "react-hook-form"; |
|||
import { |
|||
AlertTriangle, |
|||
Circle, |
|||
Clock, |
|||
Lock, |
|||
LockOpen, |
|||
Plus, |
|||
Server, |
|||
Trash2, |
|||
Users, |
|||
Wifi, |
|||
} from "lucide-react"; |
|||
import { useMessageStore } from "@core/stores/messageStore/index.ts"; |
|||
import type { SavedServer } from "@core/stores/appStore.ts"; |
|||
|
|||
interface AddServerFormData { |
|||
hostname: string; |
|||
secure: boolean; |
|||
} |
|||
|
|||
interface HTTPServerSectionProps { |
|||
onConnect?: () => void; |
|||
} |
|||
|
|||
export const HTTPServerSection = ({ onConnect }: HTTPServerSectionProps) => { |
|||
const [connectionInProgress, setConnectionInProgress] = useState(false); |
|||
const [connectingToServer, setConnectingToServer] = useState<string | null>( |
|||
null, |
|||
); |
|||
const [addServerOpen, setAddServerOpen] = useState(false); |
|||
const [connectionError, setConnectionError] = useState<string | null>(null); |
|||
const isURLHTTPS = location.protocol === "https:"; |
|||
|
|||
const { addDevice } = useDeviceStore(); |
|||
const messageStore = useMessageStore(); |
|||
const { |
|||
setSelectedDevice, |
|||
addSavedServer, |
|||
removeSavedServer, |
|||
clearSavedServers, |
|||
updateServerStatus, |
|||
getSavedServers, |
|||
} = useAppStore(); |
|||
|
|||
const savedServers = getSavedServers(); |
|||
|
|||
const { register, handleSubmit, reset, setValue, watch } = useForm< |
|||
AddServerFormData |
|||
>({ |
|||
defaultValues: { |
|||
hostname: ["client.meshtastic.org", "localhost"].includes( |
|||
globalThis.location.hostname, |
|||
) |
|||
? "meshtastic.local" |
|||
: globalThis.location.host, |
|||
secure: isURLHTTPS, |
|||
}, |
|||
}); |
|||
|
|||
const secureValue = watch("secure"); |
|||
|
|||
// Auto-check server status on component mount
|
|||
useEffect(() => { |
|||
savedServers.forEach((server) => { |
|||
if (server.status !== "checking") { |
|||
checkServerStatus(server); |
|||
} |
|||
}); |
|||
}, []); |
|||
|
|||
const checkServerStatus = async (server: SavedServer) => { |
|||
updateServerStatus(server.url, "checking"); |
|||
|
|||
try { |
|||
const controller = new AbortController(); |
|||
const timeoutId = setTimeout(() => controller.abort(), 8000); |
|||
|
|||
const response = await fetch(`${server.url}/api/v1/fromradio?all=false`, { |
|||
method: "GET", |
|||
mode: "cors", |
|||
signal: controller.signal, |
|||
}); |
|||
|
|||
clearTimeout(timeoutId); |
|||
|
|||
if (response.ok) { |
|||
updateServerStatus(server.url, "online"); |
|||
} else { |
|||
const fallbackResponse = await fetch(`${server.url}/`, { |
|||
method: "GET", |
|||
mode: "cors", |
|||
signal: AbortSignal.timeout(3000), |
|||
}); |
|||
updateServerStatus( |
|||
server.url, |
|||
fallbackResponse.ok ? "online" : "offline", |
|||
); |
|||
} |
|||
} catch { |
|||
updateServerStatus(server.url, "offline"); |
|||
} |
|||
}; |
|||
|
|||
const connectToServer = async (server: SavedServer) => { |
|||
setConnectingToServer(server.url); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const id = randId(); |
|||
const transport = await TransportHTTP.create( |
|||
server.host, |
|||
server.protocol === "https", |
|||
); |
|||
const device = addDevice(id); |
|||
const connection = new MeshDevice(transport, id); |
|||
connection.configure(); |
|||
setSelectedDevice(id); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection, messageStore); |
|||
|
|||
addSavedServer(server.host, server.protocol); |
|||
updateServerStatus(server.url, "online"); |
|||
|
|||
onConnect?.(); |
|||
} catch (error) { |
|||
console.error("Connection error:", error); |
|||
setConnectionError(`Failed to connect to ${server.host}`); |
|||
updateServerStatus(server.url, "offline"); |
|||
} finally { |
|||
setConnectingToServer(null); |
|||
} |
|||
}; |
|||
|
|||
const handleAddServer = handleSubmit(async (data) => { |
|||
setConnectionInProgress(true); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const protocol = data.secure ? "https" : "http"; |
|||
const id = randId(); |
|||
const transport = await TransportHTTP.create(data.hostname, data.secure); |
|||
const device = addDevice(id); |
|||
const connection = new MeshDevice(transport, id); |
|||
connection.configure(); |
|||
setSelectedDevice(id); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection, messageStore); |
|||
|
|||
addSavedServer(data.hostname, protocol); |
|||
|
|||
setAddServerOpen(false); |
|||
reset(); |
|||
onConnect?.(); |
|||
} catch (error) { |
|||
console.error("Connection error:", error); |
|||
setConnectionError(`Failed to connect to ${data.hostname}`); |
|||
} finally { |
|||
setConnectionInProgress(false); |
|||
} |
|||
}); |
|||
|
|||
const getStatusIcon = (status?: string) => { |
|||
switch (status) { |
|||
case "online": |
|||
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />; |
|||
case "offline": |
|||
return <Circle className="h-3 w-3 fill-red-500 text-red-500" />; |
|||
case "checking": |
|||
return ( |
|||
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" /> |
|||
); |
|||
default: |
|||
return <Circle className="h-3 w-3 fill-slate-300 text-slate-300" />; |
|||
} |
|||
}; |
|||
|
|||
const getSecurityIcon = (protocol: "http" | "https") => { |
|||
return protocol === "https" |
|||
? <Lock className="h-4 w-4 text-green-600" /> |
|||
: <LockOpen className="h-4 w-4 text-yellow-600" />; |
|||
}; |
|||
|
|||
const getStatusText = (status?: string) => { |
|||
switch (status) { |
|||
case "online": |
|||
return "Online"; |
|||
case "offline": |
|||
return "Offline"; |
|||
case "checking": |
|||
return "Checking..."; |
|||
default: |
|||
return "Unknown"; |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<> |
|||
<div className="space-y-3"> |
|||
{/* Header */} |
|||
<div className="flex items-center justify-between"> |
|||
<div className="flex items-center gap-2"> |
|||
<Server className="h-5 w-5 text-blue-600" /> |
|||
<h3 className="font-semibold text-slate-900 dark:text-slate-100"> |
|||
HTTP Servers |
|||
</h3> |
|||
</div> |
|||
{savedServers.length > 0 && ( |
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={clearSavedServers} |
|||
className="text-red-600 hover:text-red-700" |
|||
> |
|||
Clear All |
|||
</Button> |
|||
)} |
|||
</div> |
|||
|
|||
{/* Server List */} |
|||
<div className="space-y-2 bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4 border border-slate-200 dark:border-slate-700"> |
|||
{savedServers.length === 0 |
|||
? ( |
|||
<div className="text-center py-4 text-slate-500 dark:text-slate-400"> |
|||
<Server className="h-8 w-8 mx-auto mb-2 text-slate-300" /> |
|||
<p className="text-sm">No HTTP servers added yet</p> |
|||
</div> |
|||
) |
|||
: ( |
|||
savedServers.slice(0, 5).map((server) => ( |
|||
<div |
|||
key={server.url} |
|||
className="flex items-center gap-3 p-3 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 hover:shadow-sm transition-shadow" |
|||
> |
|||
{/* Status */} |
|||
<div className="flex-shrink-0"> |
|||
{getStatusIcon(server.status)} |
|||
</div> |
|||
|
|||
{/* Server Info */} |
|||
<div className="flex-1 min-w-0"> |
|||
<div className="flex items-center gap-2"> |
|||
<span className="font-medium text-slate-900 dark:text-slate-100 truncate"> |
|||
{server.host} |
|||
</span> |
|||
{getSecurityIcon(server.protocol)} |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mt-1"> |
|||
<span>{getStatusText(server.status)}</span> |
|||
|
|||
{server.deviceInfo?.model && ( |
|||
<> |
|||
<span>•</span> |
|||
<span className="font-mono bg-slate-200 dark:bg-slate-600 px-1 rounded"> |
|||
{server.deviceInfo.model} |
|||
</span> |
|||
</> |
|||
)} |
|||
|
|||
{server.deviceInfo?.nodeCount && ( |
|||
<> |
|||
<span>•</span> |
|||
<span className="flex items-center gap-1"> |
|||
<Users className="h-3 w-3" /> |
|||
{server.deviceInfo.nodeCount} |
|||
</span> |
|||
</> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Actions */} |
|||
<div className="flex items-center gap-1"> |
|||
<Button |
|||
size="sm" |
|||
onClick={() => connectToServer(server)} |
|||
disabled={connectingToServer === server.url} |
|||
> |
|||
{connectingToServer === server.url |
|||
? <Clock className="h-3 w-3 mr-1 animate-spin" /> |
|||
: <Wifi className="h-3 w-3 mr-1" />} |
|||
Connect |
|||
</Button> |
|||
|
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => removeSavedServer(server.url)} |
|||
className="text-slate-400 hover:text-red-600 p-1" |
|||
> |
|||
<Trash2 className="h-3 w-3" /> |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
)) |
|||
)} |
|||
|
|||
{/* Add Server Button */} |
|||
<Button |
|||
variant="outline" |
|||
onClick={() => setAddServerOpen(true)} |
|||
className="w-full border-dashed hover:border-solid" |
|||
> |
|||
<Plus className="h-4 w-4 mr-2" /> |
|||
Add HTTP Server |
|||
</Button> |
|||
|
|||
{/* Connection Error */} |
|||
{connectionError && ( |
|||
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800"> |
|||
<div className="flex gap-2 items-start"> |
|||
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" /> |
|||
<p className="text-sm text-red-800 dark:text-red-200"> |
|||
{connectionError} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Add Server Dialog */} |
|||
<Dialog open={addServerOpen} onOpenChange={setAddServerOpen}> |
|||
<DialogContent className="sm:max-w-md"> |
|||
<DialogHeader> |
|||
<DialogTitle className="flex items-center gap-2"> |
|||
<Plus className="h-4 w-4" /> |
|||
Add HTTP Server |
|||
</DialogTitle> |
|||
</DialogHeader> |
|||
|
|||
<form onSubmit={handleAddServer} className="space-y-4"> |
|||
<div> |
|||
<Label htmlFor="hostname">Hostname or IP Address</Label> |
|||
<Input |
|||
id="hostname" |
|||
placeholder="meshtastic.local or 192.168.1.100" |
|||
className="mt-1" |
|||
{...register("hostname", { required: true })} |
|||
/> |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-2"> |
|||
<Switch |
|||
checked={isURLHTTPS || secureValue} |
|||
onCheckedChange={(checked) => setValue("secure", checked)} |
|||
disabled={isURLHTTPS} |
|||
{...register("secure")} |
|||
/> |
|||
<Label className="flex items-center gap-2"> |
|||
{secureValue |
|||
? <Lock className="h-4 w-4 text-green-600" /> |
|||
: <LockOpen className="h-4 w-4 text-yellow-600" />} |
|||
Use HTTPS (Secure) |
|||
</Label> |
|||
</div> |
|||
|
|||
<div className="flex gap-2 pt-4"> |
|||
<Button |
|||
type="button" |
|||
variant="outline" |
|||
onClick={() => setAddServerOpen(false)} |
|||
className="flex-1" |
|||
> |
|||
Cancel |
|||
</Button> |
|||
<Button |
|||
type="submit" |
|||
disabled={connectionInProgress} |
|||
className="flex-1" |
|||
> |
|||
{connectionInProgress |
|||
? ( |
|||
<> |
|||
<Clock className="h-4 w-4 mr-2 animate-spin" /> |
|||
Connecting... |
|||
</> |
|||
) |
|||
: ( |
|||
<> |
|||
<Wifi className="h-4 w-4 mr-2" /> |
|||
Connect |
|||
</> |
|||
)} |
|||
</Button> |
|||
</div> |
|||
</form> |
|||
</DialogContent> |
|||
</Dialog> |
|||
</> |
|||
); |
|||
}; |
|||
@ -0,0 +1,233 @@ |
|||
import { useCallback, useEffect, useState } from "react"; |
|||
import { Button } from "@components/UI/Button.tsx"; |
|||
import { useAppStore } from "@core/stores/appStore.ts"; |
|||
import { useDeviceStore } from "@core/stores/deviceStore.ts"; |
|||
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 { AlertTriangle, Circle, Clock, Plus, Trash2, Usb } from "lucide-react"; |
|||
import { useMessageStore } from "@core/stores/messageStore/index.ts"; |
|||
|
|||
interface SerialSectionProps { |
|||
onConnect?: () => void; |
|||
} |
|||
|
|||
export const SerialSection = ({ onConnect }: SerialSectionProps) => { |
|||
const [connectionInProgress, setConnectionInProgress] = useState(false); |
|||
const [connectingToPort, setConnectingToPort] = useState<SerialPort | null>( |
|||
null, |
|||
); |
|||
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]); |
|||
const [connectionError, setConnectionError] = useState<string | null>(null); |
|||
|
|||
const { addDevice } = useDeviceStore(); |
|||
const messageStore = useMessageStore(); |
|||
const { setSelectedDevice } = useAppStore(); |
|||
|
|||
const updateSerialPortList = useCallback(async () => { |
|||
try { |
|||
setSerialPorts(await navigator?.serial?.getPorts() || []); |
|||
} catch (error) { |
|||
console.error("Error getting serial ports:", error); |
|||
} |
|||
}, []); |
|||
|
|||
useEffect(() => { |
|||
const handleConnect = () => updateSerialPortList(); |
|||
const handleDisconnect = () => updateSerialPortList(); |
|||
|
|||
navigator?.serial?.addEventListener("connect", handleConnect); |
|||
navigator?.serial?.addEventListener("disconnect", handleDisconnect); |
|||
|
|||
updateSerialPortList(); |
|||
|
|||
return () => { |
|||
navigator?.serial?.removeEventListener("connect", handleConnect); |
|||
navigator?.serial?.removeEventListener("disconnect", handleDisconnect); |
|||
}; |
|||
}, [updateSerialPortList]); |
|||
|
|||
const connectToPort = async (port: SerialPort) => { |
|||
setConnectingToPort(port); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const id = randId(); |
|||
const device = addDevice(id); |
|||
setSelectedDevice(id); |
|||
const transport = await TransportWebSerial.createFromPort(port); |
|||
const connection = new MeshDevice(transport, id); |
|||
connection.configure(); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection, messageStore); |
|||
|
|||
onConnect?.(); |
|||
} catch (error) { |
|||
console.error("Serial connection error:", error); |
|||
setConnectionError("Failed to connect to serial device"); |
|||
} finally { |
|||
setConnectingToPort(null); |
|||
} |
|||
}; |
|||
|
|||
const handleAddSerialDevice = async () => { |
|||
setConnectionInProgress(true); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const port = await navigator.serial.requestPort(); |
|||
setSerialPorts([...serialPorts, port]); |
|||
} catch (error) { |
|||
console.error("Error requesting port:", error); |
|||
if (error instanceof Error && !error.message.includes("cancelled")) { |
|||
setConnectionError("Failed to add serial device"); |
|||
} |
|||
} finally { |
|||
setConnectionInProgress(false); |
|||
} |
|||
}; |
|||
|
|||
const removeSerialPort = (portToRemove: SerialPort) => { |
|||
setSerialPorts(serialPorts.filter((port) => port !== portToRemove)); |
|||
}; |
|||
|
|||
const getPortInfo = (port: SerialPort, index: number) => { |
|||
const { usbProductId, usbVendorId } = port.getInfo(); |
|||
const vendor = usbVendorId |
|||
? `0x${usbVendorId.toString(16).padStart(4, "0")}` |
|||
: "Unknown"; |
|||
const product = usbProductId |
|||
? `0x${usbProductId.toString(16).padStart(4, "0")}` |
|||
: "Unknown"; |
|||
return `Serial Port ${index + 1} (${vendor}:${product})`; |
|||
}; |
|||
|
|||
const getStatusIcon = (port: SerialPort) => { |
|||
const isConnecting = connectingToPort === port; |
|||
const isConnected = port.readable !== null; |
|||
|
|||
if (isConnecting) { |
|||
return ( |
|||
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" /> |
|||
); |
|||
} else if (isConnected) { |
|||
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />; |
|||
} else { |
|||
return <Circle className="h-3 w-3 fill-slate-400 text-slate-400" />; |
|||
} |
|||
}; |
|||
|
|||
const getStatusText = (port: SerialPort) => { |
|||
if (connectingToPort === port) return "Connecting..."; |
|||
return port.readable !== null ? "Connected" : "Available"; |
|||
}; |
|||
|
|||
return ( |
|||
<div className="space-y-3"> |
|||
{/* Header */} |
|||
<div className="flex items-center gap-2"> |
|||
<Usb className="h-5 w-5 text-green-600" /> |
|||
<h3 className="font-semibold text-slate-900 dark:text-slate-100"> |
|||
Serial Devices |
|||
</h3> |
|||
</div> |
|||
|
|||
{/* Device List */} |
|||
<div className="space-y-2 bg-slate-50 dark:bg-slate-800/50 rounded-lg p-4 border border-slate-200 dark:border-slate-700"> |
|||
{serialPorts.length === 0 |
|||
? ( |
|||
<div className="text-center py-4 text-slate-500 dark:text-slate-400"> |
|||
<Usb className="h-8 w-8 mx-auto mb-2 text-slate-300" /> |
|||
<p className="text-sm">No serial devices connected</p> |
|||
</div> |
|||
) |
|||
: ( |
|||
serialPorts.map((port, index) => ( |
|||
<div |
|||
key={`${port}-${index}`} |
|||
className="flex items-center gap-3 p-3 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 hover:shadow-sm transition-shadow" |
|||
> |
|||
{/* Status */} |
|||
<div className="flex-shrink-0"> |
|||
{getStatusIcon(port)} |
|||
</div> |
|||
|
|||
{/* Port Info */} |
|||
<div className="flex-1 min-w-0"> |
|||
<div className="flex items-center gap-2"> |
|||
<span className="font-medium text-slate-900 dark:text-slate-100 truncate"> |
|||
{getPortInfo(port, index)} |
|||
</span> |
|||
<Usb className="h-4 w-4 text-green-500" /> |
|||
</div> |
|||
|
|||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1"> |
|||
{getStatusText(port)} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Actions */} |
|||
<div className="flex items-center gap-1"> |
|||
<Button |
|||
size="sm" |
|||
onClick={() => connectToPort(port)} |
|||
disabled={connectingToPort === port || |
|||
port.readable !== null} |
|||
> |
|||
{connectingToPort === port |
|||
? <Clock className="h-3 w-3 mr-1 animate-spin" /> |
|||
: <Usb className="h-3 w-3 mr-1" />} |
|||
{port.readable !== null ? "Connected" : "Connect"} |
|||
</Button> |
|||
|
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => removeSerialPort(port)} |
|||
className="text-slate-400 hover:text-red-600 p-1" |
|||
> |
|||
<Trash2 className="h-3 w-3" /> |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
)) |
|||
)} |
|||
|
|||
{/* Add Serial Device Button */} |
|||
<Button |
|||
variant="outline" |
|||
onClick={handleAddSerialDevice} |
|||
disabled={connectionInProgress} |
|||
className="w-full border-dashed hover:border-solid" |
|||
> |
|||
{connectionInProgress |
|||
? ( |
|||
<> |
|||
<Clock className="h-4 w-4 mr-2 animate-spin" /> |
|||
Adding... |
|||
</> |
|||
) |
|||
: ( |
|||
<> |
|||
<Plus className="h-4 w-4 mr-2" /> |
|||
Add Serial Device |
|||
</> |
|||
)} |
|||
</Button> |
|||
|
|||
{/* Connection Error */} |
|||
{connectionError && ( |
|||
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800"> |
|||
<div className="flex gap-2 items-start"> |
|||
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" /> |
|||
<p className="text-sm text-red-800 dark:text-red-200"> |
|||
{connectionError} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -0,0 +1,38 @@ |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog.tsx"; |
|||
import { ConnectionTabs } from "@components/ConnectionTabs/ConnectionTabs.tsx"; |
|||
import { useTranslation } from "react-i18next"; |
|||
|
|||
export interface NewConnectionDialogProps { |
|||
open: boolean; |
|||
onOpenChange: (open: boolean) => void; |
|||
} |
|||
|
|||
export const NewConnectionDialog = ({ |
|||
open, |
|||
onOpenChange, |
|||
}: NewConnectionDialogProps) => { |
|||
const { t } = useTranslation("dialog"); |
|||
|
|||
const handleClose = () => { |
|||
onOpenChange(false); |
|||
}; |
|||
|
|||
return ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent className="sm:max-w-4xl max-h-[80vh] overflow-hidden"> |
|||
<DialogHeader> |
|||
<DialogTitle className="text-xl"> |
|||
{t("newDeviceDialog.title")} |
|||
</DialogTitle> |
|||
</DialogHeader> |
|||
|
|||
<ConnectionTabs closeDialog={handleClose} /> |
|||
</DialogContent> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
@ -0,0 +1,237 @@ |
|||
import { useCallback, useEffect, useState } from "react"; |
|||
import { Button } from "@components/UI/Button.tsx"; |
|||
import { useAppStore } from "@core/stores/appStore.ts"; |
|||
import { useDeviceStore } from "@core/stores/deviceStore.ts"; |
|||
import { subscribeAll } from "@core/subscriptions.ts"; |
|||
import { randId } from "@core/utils/randId.ts"; |
|||
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; |
|||
import { MeshDevice } from "@meshtastic/core"; |
|||
import { |
|||
AlertTriangle, |
|||
Bluetooth, |
|||
Circle, |
|||
Clock, |
|||
Plus, |
|||
Trash2, |
|||
} from "lucide-react"; |
|||
import { useMessageStore } from "@core/stores/messageStore/index.ts"; |
|||
|
|||
interface BluetoothTabProps { |
|||
closeDialog: () => void; |
|||
} |
|||
|
|||
export const BluetoothTab = ({ closeDialog }: BluetoothTabProps) => { |
|||
const [connectionInProgress, setConnectionInProgress] = useState(false); |
|||
const [connectingToDevice, setConnectingToDevice] = useState<string | null>( |
|||
null, |
|||
); |
|||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]); |
|||
const [connectionError, setConnectionError] = useState<string | null>(null); |
|||
|
|||
const { addDevice } = useDeviceStore(); |
|||
const messageStore = useMessageStore(); |
|||
const { setSelectedDevice } = useAppStore(); |
|||
|
|||
const updateBleDeviceList = useCallback(async (): Promise<void> => { |
|||
try { |
|||
// Check if Web Bluetooth API and getDevices method are available
|
|||
if ( |
|||
!navigator.bluetooth || |
|||
typeof navigator.bluetooth.getDevices !== "function" |
|||
) { |
|||
console.warn( |
|||
"Web Bluetooth API getDevices() not supported in this browser", |
|||
); |
|||
return; |
|||
} |
|||
setBleDevices(await navigator.bluetooth.getDevices()); |
|||
} catch (error) { |
|||
console.error("Error getting Bluetooth devices:", error); |
|||
} |
|||
}, []); |
|||
|
|||
useEffect(() => { |
|||
updateBleDeviceList(); |
|||
}, [updateBleDeviceList]); |
|||
|
|||
const connectToDevice = async (bleDevice: BluetoothDevice) => { |
|||
setConnectingToDevice(bleDevice.id); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const id = randId(); |
|||
const transport = await TransportWebBluetooth.createFromDevice(bleDevice); |
|||
const device = addDevice(id); |
|||
const connection = new MeshDevice(transport, id); |
|||
connection.configure(); |
|||
setSelectedDevice(id); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection, messageStore); |
|||
|
|||
closeDialog(); |
|||
} catch (error) { |
|||
console.error("Bluetooth connection error:", error); |
|||
setConnectionError(`Failed to connect to ${bleDevice.name ?? "device"}`); |
|||
} finally { |
|||
setConnectingToDevice(null); |
|||
} |
|||
}; |
|||
|
|||
const handlePairNewDevice = async () => { |
|||
setConnectionInProgress(true); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const device = await navigator.bluetooth.requestDevice({ |
|||
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }], |
|||
}); |
|||
|
|||
const exists = bleDevices.findIndex((d) => d.id === device.id); |
|||
if (exists === -1) { |
|||
setBleDevices([...bleDevices, device]); |
|||
} |
|||
} catch (error) { |
|||
console.error("Error pairing device:", error); |
|||
if (error instanceof Error && !error.message.includes("cancelled")) { |
|||
setConnectionError("Failed to pair new device"); |
|||
} |
|||
} finally { |
|||
setConnectionInProgress(false); |
|||
} |
|||
}; |
|||
|
|||
const removeBleDevice = (deviceId: string) => { |
|||
setBleDevices(bleDevices.filter((d) => d.id !== deviceId)); |
|||
}; |
|||
|
|||
const getStatusIcon = (device: BluetoothDevice) => { |
|||
const isConnecting = connectingToDevice === device.id; |
|||
const isConnected = device.gatt?.connected; |
|||
|
|||
if (isConnecting) { |
|||
return ( |
|||
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" /> |
|||
); |
|||
} |
|||
if (isConnected) { |
|||
return <Circle className="h-3 w-3 fill-blue-500 text-blue-500" />; |
|||
} |
|||
return <Circle className="h-3 w-3 fill-slate-300 text-slate-300" />; |
|||
}; |
|||
|
|||
const getStatusText = (device: BluetoothDevice) => { |
|||
if (connectingToDevice === device.id) return "Connecting..."; |
|||
return device.gatt?.connected ? "Connected" : "Paired"; |
|||
}; |
|||
|
|||
return ( |
|||
<div className="space-y-4 p-4"> |
|||
{/* Header */} |
|||
<div className="flex items-center gap-2"> |
|||
<Bluetooth className="h-5 w-5 text-blue-600" /> |
|||
<h3 className="font-semibold text-slate-900 dark:text-slate-100"> |
|||
Bluetooth Devices |
|||
</h3> |
|||
</div> |
|||
|
|||
{/* Device List */} |
|||
<div className="space-y-2 min-h-[300px]"> |
|||
{bleDevices.length === 0 |
|||
? ( |
|||
<div className="text-center py-12 text-slate-500 dark:text-slate-400"> |
|||
<Bluetooth className="h-12 w-12 mx-auto mb-3 text-slate-300" /> |
|||
<p className="text-sm mb-2">No Bluetooth devices paired</p> |
|||
<p className="text-xs text-slate-400"> |
|||
Pair your first Meshtastic device |
|||
</p> |
|||
</div> |
|||
) |
|||
: ( |
|||
bleDevices.map((device) => ( |
|||
<div |
|||
key={device.id} |
|||
className="flex items-center gap-3 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" |
|||
> |
|||
{/* Status */} |
|||
<div className="flex-shrink-0"> |
|||
{getStatusIcon(device)} |
|||
</div> |
|||
|
|||
{/* Device Info */} |
|||
<div className="flex-1 min-w-0"> |
|||
<div className="flex items-center gap-2"> |
|||
<span className="font-medium text-slate-900 dark:text-slate-100 truncate"> |
|||
{device.name ?? "Unknown Device"} |
|||
</span> |
|||
<Bluetooth className="h-4 w-4 text-blue-500" /> |
|||
</div> |
|||
|
|||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1"> |
|||
{getStatusText(device)} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Actions */} |
|||
<div className="flex items-center gap-1"> |
|||
<Button |
|||
size="sm" |
|||
onClick={() => connectToDevice(device)} |
|||
disabled={connectingToDevice === device.id || |
|||
device.gatt?.connected} |
|||
> |
|||
{connectingToDevice === device.id |
|||
? <Clock className="h-3 w-3 mr-1 animate-spin" /> |
|||
: <Bluetooth className="h-3 w-3 mr-1" />} |
|||
{device.gatt?.connected ? "Connected" : "Connect"} |
|||
</Button> |
|||
|
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => removeBleDevice(device.id)} |
|||
className="text-slate-400 hover:text-red-600 p-1" |
|||
> |
|||
<Trash2 className="h-3 w-3" /> |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
)) |
|||
)} |
|||
|
|||
{/* Pair New Device Button */} |
|||
<Button |
|||
variant="outline" |
|||
onClick={handlePairNewDevice} |
|||
disabled={connectionInProgress} |
|||
className="w-full border-dashed hover:border-solid" |
|||
> |
|||
{connectionInProgress |
|||
? ( |
|||
<> |
|||
<Clock className="h-4 w-4 mr-2 animate-spin" /> |
|||
Pairing... |
|||
</> |
|||
) |
|||
: ( |
|||
<> |
|||
<Plus className="h-4 w-4 mr-2" /> |
|||
Pair New Device |
|||
</> |
|||
)} |
|||
</Button> |
|||
|
|||
{/* Connection Error */} |
|||
{connectionError && ( |
|||
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800"> |
|||
<div className="flex gap-2 items-start"> |
|||
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" /> |
|||
<p className="text-sm text-red-800 dark:text-red-200"> |
|||
{connectionError} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -0,0 +1,674 @@ |
|||
import { useEffect, useState } from "react"; |
|||
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 { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog.tsx"; |
|||
import { useAppStore } from "@core/stores/appStore.ts"; |
|||
import { useDeviceStore } from "@core/stores/deviceStore.ts"; |
|||
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 { useForm } from "react-hook-form"; |
|||
import { |
|||
AlertTriangle, |
|||
Circle, |
|||
Clock, |
|||
Edit, |
|||
Lock, |
|||
LockOpen, |
|||
Plus, |
|||
Server, |
|||
Trash2, |
|||
Users, |
|||
Wifi, |
|||
} from "lucide-react"; |
|||
import { useMessageStore } from "@core/stores/messageStore/index.ts"; |
|||
import type { SavedServer } from "@core/stores/appStore.ts"; |
|||
|
|||
interface AddServerFormData { |
|||
hostname: string; |
|||
secure: boolean; |
|||
} |
|||
|
|||
interface EditServerFormData { |
|||
hostname: string; |
|||
secure: boolean; |
|||
} |
|||
|
|||
interface HTTPTabProps { |
|||
closeDialog: () => void; |
|||
} |
|||
|
|||
export const HTTPTab = ({ closeDialog }: HTTPTabProps) => { |
|||
const [connectionInProgress, setConnectionInProgress] = useState(false); |
|||
const [connectingToServer, setConnectingToServer] = useState<string | null>( |
|||
null, |
|||
); |
|||
const [addServerOpen, setAddServerOpen] = useState(false); |
|||
const [editServerOpen, setEditServerOpen] = useState(false); |
|||
const [editingServer, setEditingServer] = useState<SavedServer | null>(null); |
|||
const [connectionError, setConnectionError] = useState<string | null>(null); |
|||
const [addServerError, setAddServerError] = useState<string | null>(null); |
|||
const [pendingConnection, setPendingConnection] = useState< |
|||
AddServerFormData | null |
|||
>(null); |
|||
const isURLHTTPS = location.protocol === "https:"; |
|||
|
|||
const { addDevice } = useDeviceStore(); |
|||
const messageStore = useMessageStore(); |
|||
const { |
|||
setSelectedDevice, |
|||
addSavedServer, |
|||
removeSavedServer, |
|||
clearSavedServers, |
|||
updateServerStatus, |
|||
getSavedServers, |
|||
} = useAppStore(); |
|||
|
|||
const savedServers = getSavedServers(); |
|||
|
|||
const { |
|||
register: registerAdd, |
|||
handleSubmit: handleSubmitAdd, |
|||
reset: resetAdd, |
|||
setValue: setValueAdd, |
|||
watch: watchAdd, |
|||
} = useForm<AddServerFormData>({ |
|||
defaultValues: { |
|||
hostname: ["client.meshtastic.org", "localhost"].includes( |
|||
globalThis.location.hostname, |
|||
) |
|||
? "meshtastic.local" |
|||
: globalThis.location.host, |
|||
secure: isURLHTTPS, |
|||
}, |
|||
}); |
|||
|
|||
const { |
|||
register: registerEdit, |
|||
handleSubmit: handleSubmitEdit, |
|||
reset: resetEdit, |
|||
setValue: setValueEdit, |
|||
watch: watchEdit, |
|||
} = useForm<EditServerFormData>({ |
|||
defaultValues: { |
|||
hostname: "", |
|||
secure: false, |
|||
}, |
|||
}); |
|||
|
|||
const secureValueAdd = watchAdd("secure"); |
|||
const secureValueEdit = watchEdit("secure"); |
|||
|
|||
// Auto-check server status on component mount
|
|||
useEffect(() => { |
|||
for (const server of savedServers) { |
|||
if (server.status !== "checking") { |
|||
checkServerStatus(server); |
|||
} |
|||
} |
|||
}, [savedServers]); |
|||
|
|||
const retryCertConnection = () => { |
|||
if (!pendingConnection) return; |
|||
|
|||
setAddServerError(null); |
|||
const connectionData = pendingConnection; |
|||
setPendingConnection(null); |
|||
|
|||
// Wait a moment for the certificate to be trusted, then retry
|
|||
setTimeout(async () => { |
|||
try { |
|||
await attemptConnection(connectionData); |
|||
} catch (error) { |
|||
console.error("Retry connection failed:", error); |
|||
setPendingConnection(connectionData); |
|||
setAddServerError( |
|||
"Connection still failed. Make sure you accepted the certificate in the device page.", |
|||
); |
|||
} |
|||
}, 1000); |
|||
}; |
|||
|
|||
const attemptConnection = async (data: AddServerFormData) => { |
|||
const protocol = data.secure ? "https" : "http"; |
|||
|
|||
// Add default ports if not specified
|
|||
let hostname = data.hostname; |
|||
if (!hostname.includes(":")) { |
|||
const defaultPort = data.secure ? "443" : "4403"; |
|||
hostname = `${hostname}:${defaultPort}`; |
|||
} |
|||
|
|||
const id = randId(); |
|||
const transport = await TransportHTTP.create(hostname, data.secure); |
|||
const device = addDevice(id); |
|||
const connection = new MeshDevice(transport, id); |
|||
connection.configure(); |
|||
setSelectedDevice(id); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection, messageStore); |
|||
|
|||
addSavedServer(hostname, protocol); |
|||
|
|||
setAddServerOpen(false); |
|||
resetAdd(); |
|||
closeDialog(); |
|||
}; |
|||
|
|||
const checkServerStatus = async (server: SavedServer) => { |
|||
updateServerStatus(server.url, "checking"); |
|||
|
|||
try { |
|||
const controller = new AbortController(); |
|||
const timeoutId = setTimeout(() => controller.abort(), 8000); |
|||
|
|||
const response = await fetch(`${server.url}/api/v1/fromradio?all=false`, { |
|||
method: "GET", |
|||
mode: "cors", |
|||
signal: controller.signal, |
|||
}); |
|||
|
|||
clearTimeout(timeoutId); |
|||
|
|||
if (response.ok) { |
|||
updateServerStatus(server.url, "online"); |
|||
} else { |
|||
const fallbackResponse = await fetch(`${server.url}/`, { |
|||
method: "GET", |
|||
mode: "cors", |
|||
signal: AbortSignal.timeout(3000), |
|||
}); |
|||
updateServerStatus( |
|||
server.url, |
|||
fallbackResponse.ok ? "online" : "offline", |
|||
); |
|||
} |
|||
} catch (error) { |
|||
console.error(`Failed to check server status for ${server.url}:`, error); |
|||
updateServerStatus(server.url, "offline"); |
|||
} |
|||
}; |
|||
|
|||
const connectToServer = async (server: SavedServer) => { |
|||
setConnectingToServer(server.url); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const id = randId(); |
|||
const transport = await TransportHTTP.create( |
|||
server.host, |
|||
server.protocol === "https", |
|||
); |
|||
const device = addDevice(id); |
|||
const connection = new MeshDevice(transport, id); |
|||
connection.configure(); |
|||
setSelectedDevice(id); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection, messageStore); |
|||
|
|||
addSavedServer(server.host, server.protocol); |
|||
updateServerStatus(server.url, "online"); |
|||
|
|||
closeDialog(); |
|||
} catch (error) { |
|||
console.error("Connection error:", error); |
|||
setConnectionError(`Failed to connect to ${server.host}`); |
|||
updateServerStatus(server.url, "offline"); |
|||
} finally { |
|||
setConnectingToServer(null); |
|||
} |
|||
}; |
|||
|
|||
const formatHostname = (hostname: string, secure: boolean) => { |
|||
if (hostname.includes(":")) return hostname; |
|||
const defaultPort = secure ? "443" : "4403"; |
|||
return `${hostname}:${defaultPort}`; |
|||
}; |
|||
|
|||
const isCertificateError = (errorMessage: string, isSecure: boolean) => { |
|||
if (!isSecure) return false; |
|||
return errorMessage.includes("certificate") || |
|||
errorMessage.includes("SSL") || |
|||
errorMessage.includes("TLS") || |
|||
errorMessage.includes("CERT_") || |
|||
errorMessage.includes("net::ERR_CERT"); |
|||
}; |
|||
|
|||
const isNetworkError = (errorMessage: string) => { |
|||
return errorMessage.includes("network") || |
|||
errorMessage.includes("fetch") || |
|||
errorMessage.includes("CONNECTION_REFUSED"); |
|||
}; |
|||
|
|||
const handleConnectionError = (error: unknown, data: AddServerFormData) => { |
|||
const errorMessage = error instanceof Error |
|||
? error.message |
|||
: `Failed to connect to ${data.hostname}`; |
|||
const hostname = formatHostname(data.hostname, data.secure); |
|||
|
|||
if (isCertificateError(errorMessage, data.secure)) { |
|||
setPendingConnection(data); |
|||
setAddServerError( |
|||
`SSL certificate error connecting to ${hostname}. Click "Open Device Page" to accept the certificate, then "Retry Connection".`, |
|||
); |
|||
} else if (isNetworkError(errorMessage)) { |
|||
const defaultPort = data.secure ? "443" : "4403"; |
|||
const port = hostname.includes(":") |
|||
? hostname.split(":")[1] |
|||
: defaultPort; |
|||
setAddServerError( |
|||
`Network error connecting to ${hostname}. Check that the device is accessible and running on port ${port}.`, |
|||
); |
|||
} else { |
|||
setAddServerError(`Failed to connect to ${hostname}: ${errorMessage}`); |
|||
} |
|||
}; |
|||
|
|||
const handleAddServer = handleSubmitAdd(async (data) => { |
|||
setConnectionInProgress(true); |
|||
setAddServerError(null); |
|||
|
|||
try { |
|||
await attemptConnection(data); |
|||
} catch (error) { |
|||
console.error("Connection error:", error); |
|||
handleConnectionError(error, data); |
|||
} finally { |
|||
setConnectionInProgress(false); |
|||
} |
|||
}); |
|||
|
|||
const handleEditServer = (server: SavedServer) => { |
|||
setEditingServer(server); |
|||
resetEdit({ |
|||
hostname: server.host, |
|||
secure: server.protocol === "https", |
|||
}); |
|||
setEditServerOpen(true); |
|||
}; |
|||
|
|||
const handleSaveEdit = handleSubmitEdit((data) => { |
|||
if (!editingServer) return; |
|||
|
|||
// Remove old server and add updated one
|
|||
removeSavedServer(editingServer.url); |
|||
addSavedServer(data.hostname, data.secure ? "https" : "http"); |
|||
|
|||
setEditServerOpen(false); |
|||
setEditingServer(null); |
|||
resetEdit(); |
|||
}); |
|||
|
|||
const getStatusIcon = (status?: string) => { |
|||
switch (status) { |
|||
case "online": |
|||
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />; |
|||
case "offline": |
|||
return <Circle className="h-3 w-3 fill-red-500 text-red-500" />; |
|||
case "checking": |
|||
return ( |
|||
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" /> |
|||
); |
|||
default: |
|||
return <Circle className="h-3 w-3 fill-slate-300 text-slate-300" />; |
|||
} |
|||
}; |
|||
|
|||
const getSecurityIcon = (protocol: "http" | "https") => { |
|||
return protocol === "https" |
|||
? <Lock className="h-4 w-4 text-green-600" /> |
|||
: <LockOpen className="h-4 w-4 text-yellow-600" />; |
|||
}; |
|||
|
|||
const getStatusText = (status?: string) => { |
|||
switch (status) { |
|||
case "online": |
|||
return "Online"; |
|||
case "offline": |
|||
return "Offline"; |
|||
case "checking": |
|||
return "Checking..."; |
|||
default: |
|||
return "Unknown"; |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<> |
|||
<div className="space-y-4 p-4"> |
|||
{/* Header */} |
|||
<div className="flex items-center justify-between"> |
|||
<div className="flex items-center gap-2"> |
|||
<Server className="h-5 w-5 text-blue-600" /> |
|||
<h3 className="font-semibold text-slate-900 dark:text-slate-100"> |
|||
HTTP Servers |
|||
</h3> |
|||
</div> |
|||
{savedServers.length > 0 && ( |
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={clearSavedServers} |
|||
className="text-red-600 hover:text-red-700" |
|||
> |
|||
Clear All |
|||
</Button> |
|||
)} |
|||
</div> |
|||
|
|||
{/* Server List */} |
|||
<div className="space-y-2 min-h-[300px]"> |
|||
{savedServers.length === 0 |
|||
? ( |
|||
<div className="text-center py-12 text-slate-500 dark:text-slate-400"> |
|||
<Server className="h-12 w-12 mx-auto mb-3 text-slate-300" /> |
|||
<p className="text-sm mb-2">No HTTP servers added yet</p> |
|||
<p className="text-xs text-slate-400"> |
|||
Add your first Meshtastic server to get started |
|||
</p> |
|||
</div> |
|||
) |
|||
: ( |
|||
savedServers.map((server) => ( |
|||
<div |
|||
key={server.url} |
|||
className="flex items-center gap-3 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" |
|||
> |
|||
{/* Status */} |
|||
<div className="flex-shrink-0"> |
|||
{getStatusIcon(server.status)} |
|||
</div> |
|||
|
|||
{/* Server Info */} |
|||
<div className="flex-1 min-w-0"> |
|||
<div className="flex items-center gap-2"> |
|||
<span className="font-medium text-slate-900 dark:text-slate-100 truncate"> |
|||
{server.host?.replace(/[<>]/g, "")} |
|||
</span> |
|||
{getSecurityIcon(server.protocol)} |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mt-1"> |
|||
<span>{getStatusText(server.status)}</span> |
|||
|
|||
{server.deviceInfo?.model && ( |
|||
<> |
|||
<span>•</span> |
|||
<span className="font-mono bg-slate-200 dark:bg-slate-600 px-1 rounded"> |
|||
{server.deviceInfo.model.replace(/[<>]/g, "")} |
|||
</span> |
|||
</> |
|||
)} |
|||
|
|||
{server.deviceInfo?.nodeCount && ( |
|||
<> |
|||
<span>•</span> |
|||
<span className="flex items-center gap-1"> |
|||
<Users className="h-3 w-3" /> |
|||
{typeof server.deviceInfo.nodeCount === "number" |
|||
? server.deviceInfo.nodeCount.toString() |
|||
: "0"} |
|||
</span> |
|||
</> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Actions */} |
|||
<div className="flex items-center gap-1"> |
|||
<Button |
|||
size="sm" |
|||
onClick={() => connectToServer(server)} |
|||
disabled={connectingToServer === server.url} |
|||
> |
|||
{connectingToServer === server.url |
|||
? <Clock className="h-3 w-3 mr-1 animate-spin" /> |
|||
: <Wifi className="h-3 w-3 mr-1" />} |
|||
Connect |
|||
</Button> |
|||
|
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => handleEditServer(server)} |
|||
className="text-slate-400 hover:text-blue-600 p-1" |
|||
> |
|||
<Edit className="h-3 w-3" /> |
|||
</Button> |
|||
|
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => removeSavedServer(server.url)} |
|||
className="text-slate-400 hover:text-red-600 p-1" |
|||
> |
|||
<Trash2 className="h-3 w-3" /> |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
)) |
|||
)} |
|||
|
|||
{/* Add Server Button */} |
|||
<Button |
|||
variant="outline" |
|||
onClick={() => { |
|||
setAddServerError(null); |
|||
setAddServerOpen(true); |
|||
}} |
|||
className="w-full border-dashed hover:border-solid" |
|||
> |
|||
<Plus className="h-4 w-4 mr-2" /> |
|||
Add HTTP Server |
|||
</Button> |
|||
|
|||
{/* Connection Error */} |
|||
{connectionError && ( |
|||
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800"> |
|||
<div className="flex gap-2 items-start"> |
|||
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" /> |
|||
<p className="text-sm text-red-800 dark:text-red-200"> |
|||
{connectionError?.replace(/[<>]/g, "")} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Add Server Dialog */} |
|||
<Dialog open={addServerOpen} onOpenChange={setAddServerOpen}> |
|||
<DialogContent className="sm:max-w-md"> |
|||
<DialogHeader> |
|||
<DialogTitle className="flex items-center gap-2"> |
|||
<Plus className="h-4 w-4" /> |
|||
Add HTTP Server |
|||
</DialogTitle> |
|||
<DialogDescription> |
|||
Enter the hostname or IP address of your Meshtastic device to |
|||
connect via HTTP/HTTPS. |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
|
|||
<form onSubmit={handleAddServer} className="space-y-4"> |
|||
<div> |
|||
<Label htmlFor="hostname">Hostname or IP Address</Label> |
|||
<Input |
|||
id="hostname" |
|||
placeholder="meshtastic.local or 192.168.1.100" |
|||
className="mt-1" |
|||
{...registerAdd("hostname", { required: true })} |
|||
/> |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-2"> |
|||
<Switch |
|||
checked={isURLHTTPS || secureValueAdd} |
|||
onCheckedChange={(checked) => setValueAdd("secure", checked)} |
|||
disabled={isURLHTTPS} |
|||
{...registerAdd("secure")} |
|||
/> |
|||
<Label className="flex items-center gap-2"> |
|||
{secureValueAdd |
|||
? <Lock className="h-4 w-4 text-green-600" /> |
|||
: <LockOpen className="h-4 w-4 text-yellow-600" />} |
|||
Use HTTPS (Secure) |
|||
</Label> |
|||
</div> |
|||
|
|||
{/* Connection Error Display */} |
|||
{addServerError && ( |
|||
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800"> |
|||
<div className="flex gap-2 items-start"> |
|||
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" /> |
|||
<div className="flex-1"> |
|||
<p className="text-sm text-red-800 dark:text-red-200"> |
|||
{addServerError?.replace(/[<>]/g, "")} |
|||
</p> |
|||
{pendingConnection && ( |
|||
<div className="flex gap-2 mt-3"> |
|||
<Button |
|||
type="button" |
|||
variant="outline" |
|||
size="sm" |
|||
onClick={() => { |
|||
const protocol = pendingConnection.secure |
|||
? "https" |
|||
: "http"; |
|||
let hostname = pendingConnection.hostname; |
|||
if (!hostname.includes(":")) { |
|||
const defaultPort = pendingConnection.secure |
|||
? "443" |
|||
: "4403"; |
|||
hostname = `${hostname}:${defaultPort}`; |
|||
} |
|||
// Open the node directly to accept certificate
|
|||
const nodeUrl = `${protocol}://${hostname}`; |
|||
globalThis.open( |
|||
nodeUrl, |
|||
"_blank", |
|||
"noopener,noreferrer", |
|||
); |
|||
}} |
|||
className="text-xs" |
|||
> |
|||
Open Device Page |
|||
</Button> |
|||
<Button |
|||
type="button" |
|||
size="sm" |
|||
onClick={retryCertConnection} |
|||
className="text-xs" |
|||
> |
|||
Retry Connection |
|||
</Button> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
<div className="flex gap-2 pt-4"> |
|||
<Button |
|||
type="button" |
|||
variant="outline" |
|||
onClick={() => setAddServerOpen(false)} |
|||
className="flex-1" |
|||
> |
|||
Cancel |
|||
</Button> |
|||
<Button |
|||
type="submit" |
|||
disabled={connectionInProgress} |
|||
className="flex-1" |
|||
> |
|||
{connectionInProgress |
|||
? ( |
|||
<> |
|||
<Clock className="h-4 w-4 mr-2 animate-spin" /> |
|||
Connecting... |
|||
</> |
|||
) |
|||
: ( |
|||
<> |
|||
<Wifi className="h-4 w-4 mr-2" /> |
|||
Connect |
|||
</> |
|||
)} |
|||
</Button> |
|||
</div> |
|||
</form> |
|||
</DialogContent> |
|||
</Dialog> |
|||
|
|||
{/* Edit Server Dialog */} |
|||
<Dialog open={editServerOpen} onOpenChange={setEditServerOpen}> |
|||
<DialogContent className="sm:max-w-md"> |
|||
<DialogHeader> |
|||
<DialogTitle className="flex items-center gap-2"> |
|||
<Edit className="h-4 w-4" /> |
|||
Edit HTTP Server |
|||
</DialogTitle> |
|||
<DialogDescription> |
|||
Update the hostname or connection settings for this Meshtastic |
|||
device. |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
|
|||
<form onSubmit={handleSaveEdit} className="space-y-4"> |
|||
<div> |
|||
<Label htmlFor="edit-hostname">Hostname or IP Address</Label> |
|||
<Input |
|||
id="edit-hostname" |
|||
placeholder="meshtastic.local or 192.168.1.100" |
|||
className="mt-1" |
|||
{...registerEdit("hostname", { required: true })} |
|||
/> |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-2"> |
|||
<Switch |
|||
checked={secureValueEdit} |
|||
onCheckedChange={(checked) => setValueEdit("secure", checked)} |
|||
{...registerEdit("secure")} |
|||
/> |
|||
<Label className="flex items-center gap-2"> |
|||
{secureValueEdit |
|||
? <Lock className="h-4 w-4 text-green-600" /> |
|||
: <LockOpen className="h-4 w-4 text-yellow-600" />} |
|||
Use HTTPS (Secure) |
|||
</Label> |
|||
</div> |
|||
|
|||
<div className="flex gap-2 pt-4"> |
|||
<Button |
|||
type="button" |
|||
variant="outline" |
|||
onClick={() => setEditServerOpen(false)} |
|||
className="flex-1" |
|||
> |
|||
Cancel |
|||
</Button> |
|||
<Button |
|||
type="submit" |
|||
className="flex-1" |
|||
> |
|||
Save Changes |
|||
</Button> |
|||
</div> |
|||
</form> |
|||
</DialogContent> |
|||
</Dialog> |
|||
</> |
|||
); |
|||
}; |
|||
@ -0,0 +1,249 @@ |
|||
import { useCallback, useEffect, useState } from "react"; |
|||
import { Button } from "@components/UI/Button.tsx"; |
|||
import { useAppStore } from "@core/stores/appStore.ts"; |
|||
import { useDeviceStore } from "@core/stores/deviceStore.ts"; |
|||
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 { AlertTriangle, Circle, Clock, Plus, Trash2, Usb } from "lucide-react"; |
|||
import { useMessageStore } from "@core/stores/messageStore/index.ts"; |
|||
|
|||
interface SerialTabProps { |
|||
closeDialog: () => void; |
|||
} |
|||
|
|||
export const SerialTab = ({ closeDialog }: SerialTabProps) => { |
|||
const [connectionInProgress, setConnectionInProgress] = useState(false); |
|||
const [connectingToPort, setConnectingToPort] = useState<SerialPort | null>( |
|||
null, |
|||
); |
|||
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]); |
|||
const [connectionError, setConnectionError] = useState<string | null>(null); |
|||
|
|||
const { addDevice } = useDeviceStore(); |
|||
const messageStore = useMessageStore(); |
|||
const { setSelectedDevice } = useAppStore(); |
|||
|
|||
const updateSerialPortList = useCallback(async () => { |
|||
try { |
|||
setSerialPorts(await navigator?.serial?.getPorts() || []); |
|||
} catch (error) { |
|||
console.error("Error getting serial ports:", error); |
|||
} |
|||
}, []); |
|||
|
|||
useEffect(() => { |
|||
const handleConnect = () => updateSerialPortList(); |
|||
const handleDisconnect = () => updateSerialPortList(); |
|||
|
|||
navigator?.serial?.addEventListener("connect", handleConnect); |
|||
navigator?.serial?.addEventListener("disconnect", handleDisconnect); |
|||
|
|||
updateSerialPortList(); |
|||
|
|||
return () => { |
|||
navigator?.serial?.removeEventListener("connect", handleConnect); |
|||
navigator?.serial?.removeEventListener("disconnect", handleDisconnect); |
|||
}; |
|||
}, [updateSerialPortList]); |
|||
|
|||
const connectToPort = async (port: SerialPort) => { |
|||
setConnectingToPort(port); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const id = randId(); |
|||
const device = addDevice(id); |
|||
setSelectedDevice(id); |
|||
const transport = await TransportWebSerial.createFromPort(port); |
|||
const connection = new MeshDevice(transport, id); |
|||
connection.configure(); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection, messageStore); |
|||
|
|||
closeDialog(); |
|||
} catch (error) { |
|||
console.error("Serial connection error:", error); |
|||
setConnectionError("Failed to connect to serial device"); |
|||
} finally { |
|||
setConnectingToPort(null); |
|||
} |
|||
}; |
|||
|
|||
const handleAddSerialDevice = async () => { |
|||
setConnectionInProgress(true); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const port = await navigator.serial.requestPort(); |
|||
setSerialPorts([...serialPorts, port]); |
|||
} catch (error) { |
|||
console.error("Error requesting port:", error); |
|||
|
|||
// Check for user cancellation - different browsers use different error types/messages
|
|||
const isDOMCancellation = error instanceof DOMException && |
|||
error.name === "NotFoundError"; |
|||
const isErrorCancellation = error instanceof Error && ( |
|||
error.message.includes("cancelled") || |
|||
error.message.includes("canceled") || |
|||
error.message.includes("User cancelled") || |
|||
error.message.includes("No port selected") |
|||
); |
|||
const isUserCancellation = isDOMCancellation || isErrorCancellation; |
|||
|
|||
if (!isUserCancellation) { |
|||
setConnectionError("Failed to add serial device"); |
|||
} |
|||
// If it's user cancellation, we silently ignore it (no error shown)
|
|||
} finally { |
|||
setConnectionInProgress(false); |
|||
} |
|||
}; |
|||
|
|||
const removeSerialPort = (portToRemove: SerialPort) => { |
|||
setSerialPorts(serialPorts.filter((port) => port !== portToRemove)); |
|||
}; |
|||
|
|||
const getPortInfo = (port: SerialPort, index: number) => { |
|||
const { usbProductId, usbVendorId } = port.getInfo(); |
|||
const vendor = usbVendorId |
|||
? `0x${usbVendorId.toString(16).padStart(4, "0")}` |
|||
: "Unknown"; |
|||
const product = usbProductId |
|||
? `0x${usbProductId.toString(16).padStart(4, "0")}` |
|||
: "Unknown"; |
|||
return `Serial Port ${index + 1} (${vendor}:${product})`; |
|||
}; |
|||
|
|||
const getStatusIcon = (port: SerialPort) => { |
|||
const isConnecting = connectingToPort === port; |
|||
const isConnected = port.readable !== null; |
|||
|
|||
if (isConnecting) { |
|||
return ( |
|||
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" /> |
|||
); |
|||
} else if (isConnected) { |
|||
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />; |
|||
} else { |
|||
return <Circle className="h-3 w-3 fill-slate-400 text-slate-400" />; |
|||
} |
|||
}; |
|||
|
|||
const getStatusText = (port: SerialPort) => { |
|||
if (connectingToPort === port) return "Connecting..."; |
|||
return port.readable !== null ? "Connected" : "Available"; |
|||
}; |
|||
|
|||
return ( |
|||
<div className="space-y-4 p-4"> |
|||
{/* Header */} |
|||
<div className="flex items-center gap-2"> |
|||
<Usb className="h-5 w-5 text-green-600" /> |
|||
<h3 className="font-semibold text-slate-900 dark:text-slate-100"> |
|||
Serial Devices |
|||
</h3> |
|||
</div> |
|||
|
|||
{/* Device List */} |
|||
<div className="space-y-2 min-h-[300px]"> |
|||
{serialPorts.length === 0 |
|||
? ( |
|||
<div className="text-center py-12 text-slate-500 dark:text-slate-400"> |
|||
<Usb className="h-12 w-12 mx-auto mb-3 text-slate-300" /> |
|||
<p className="text-sm mb-2">No serial devices connected</p> |
|||
<p className="text-xs text-slate-400"> |
|||
Connect your first Meshtastic device via USB |
|||
</p> |
|||
</div> |
|||
) |
|||
: ( |
|||
serialPorts.map((port, index) => ( |
|||
<div |
|||
key={`${port}-${index}`} |
|||
className="flex items-center gap-3 p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" |
|||
> |
|||
{/* Status */} |
|||
<div className="flex-shrink-0"> |
|||
{getStatusIcon(port)} |
|||
</div> |
|||
|
|||
{/* Port Info */} |
|||
<div className="flex-1 min-w-0"> |
|||
<div className="flex items-center gap-2"> |
|||
<span className="font-medium text-slate-900 dark:text-slate-100 truncate"> |
|||
{getPortInfo(port, index)} |
|||
</span> |
|||
<Usb className="h-4 w-4 text-green-500" /> |
|||
</div> |
|||
|
|||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1"> |
|||
{getStatusText(port)} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Actions */} |
|||
<div className="flex items-center gap-1"> |
|||
<Button |
|||
size="sm" |
|||
onClick={() => connectToPort(port)} |
|||
disabled={connectingToPort === port || |
|||
port.readable !== null} |
|||
> |
|||
{connectingToPort === port |
|||
? <Clock className="h-3 w-3 mr-1 animate-spin" /> |
|||
: <Usb className="h-3 w-3 mr-1" />} |
|||
{port.readable !== null ? "Connected" : "Connect"} |
|||
</Button> |
|||
|
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => removeSerialPort(port)} |
|||
className="text-slate-400 hover:text-red-600 p-1" |
|||
> |
|||
<Trash2 className="h-3 w-3" /> |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
)) |
|||
)} |
|||
|
|||
{/* Add Serial Device Button */} |
|||
<Button |
|||
variant="outline" |
|||
onClick={handleAddSerialDevice} |
|||
disabled={connectionInProgress} |
|||
className="w-full border-dashed hover:border-solid" |
|||
> |
|||
{connectionInProgress |
|||
? ( |
|||
<> |
|||
<Clock className="h-4 w-4 mr-2 animate-spin" /> |
|||
Adding... |
|||
</> |
|||
) |
|||
: ( |
|||
<> |
|||
<Plus className="h-4 w-4 mr-2" /> |
|||
Add Serial Device |
|||
</> |
|||
)} |
|||
</Button> |
|||
|
|||
{/* Connection Error */} |
|||
{connectionError && ( |
|||
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800"> |
|||
<div className="flex gap-2 items-start"> |
|||
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" /> |
|||
<p className="text-sm text-red-800 dark:text-red-200"> |
|||
{connectionError} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,164 +1,468 @@ |
|||
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx"; |
|||
import { Button } from "@components/UI/Button.tsx"; |
|||
import { Input } from "@components/UI/Input.tsx"; |
|||
import { Link } from "@components/UI/Typography/Link.tsx"; |
|||
import { Label } from "@components/UI/Label.tsx"; |
|||
import { Switch } from "@components/UI/Switch.tsx"; |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog.tsx"; |
|||
import { useAppStore } from "@core/stores/appStore.ts"; |
|||
import { useDeviceStore } from "@core/stores/deviceStore.ts"; |
|||
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 { useState } from "react"; |
|||
import { useController, useForm } from "react-hook-form"; |
|||
import { AlertTriangle } from "lucide-react"; |
|||
import { useEffect, useState } from "react"; |
|||
import { useForm } from "react-hook-form"; |
|||
import { |
|||
AlertTriangle, |
|||
Circle, |
|||
Clock, |
|||
Lock, |
|||
LockOpen, |
|||
MessageCircle, |
|||
Plus, |
|||
Server, |
|||
Trash2, |
|||
Users, |
|||
Wifi, |
|||
} from "lucide-react"; |
|||
import { useMessageStore } from "@core/stores/messageStore/index.ts"; |
|||
import { useTranslation } from "react-i18next"; |
|||
import type { SavedServer } from "@core/stores/appStore.ts"; |
|||
|
|||
interface FormData { |
|||
ip: string; |
|||
tls: boolean; |
|||
interface AddServerFormData { |
|||
hostname: string; |
|||
secure: boolean; |
|||
} |
|||
|
|||
export const HTTP = ( |
|||
{ closeDialog }: TabElementProps, |
|||
) => { |
|||
const { t } = useTranslation("dialog"); |
|||
export const HTTP = ({ closeDialog }: TabElementProps) => { |
|||
const [connectionInProgress, setConnectionInProgress] = useState(false); |
|||
const [connectingToServer, setConnectingToServer] = useState<string | null>( |
|||
null, |
|||
); |
|||
const [addServerOpen, setAddServerOpen] = useState(false); |
|||
const [connectionError, setConnectionError] = useState<string | null>(null); |
|||
const isURLHTTPS = location.protocol === "https:"; |
|||
|
|||
const { addDevice } = useDeviceStore(); |
|||
const messageStore = useMessageStore(); |
|||
const { setSelectedDevice } = useAppStore(); |
|||
const { |
|||
setSelectedDevice, |
|||
addSavedServer, |
|||
removeSavedServer, |
|||
clearSavedServers, |
|||
updateServerStatus, |
|||
getSavedServers, |
|||
} = useAppStore(); |
|||
|
|||
const { control, handleSubmit, register } = useForm<FormData>({ |
|||
const savedServers = getSavedServers(); |
|||
|
|||
const { register, handleSubmit, reset, setValue, watch } = useForm< |
|||
AddServerFormData |
|||
>({ |
|||
defaultValues: { |
|||
ip: ["client.meshtastic.org", "localhost"].includes( |
|||
hostname: ["client.meshtastic.org", "localhost"].includes( |
|||
globalThis.location.hostname, |
|||
) |
|||
? "meshtastic.local" |
|||
: globalThis.location.host, |
|||
tls: isURLHTTPS ? true : false, |
|||
secure: isURLHTTPS, |
|||
}, |
|||
}); |
|||
|
|||
const { |
|||
field: { value: tlsValue, onChange: setTLS }, |
|||
} = useController({ name: "tls", control }); |
|||
const secureValue = watch("secure"); |
|||
|
|||
// Auto-check server status on component mount
|
|||
useEffect(() => { |
|||
savedServers.forEach((server) => { |
|||
if (server.status !== "checking") { |
|||
checkServerStatus(server); |
|||
} |
|||
}); |
|||
}, []); |
|||
|
|||
const checkServerStatus = async (server: SavedServer) => { |
|||
updateServerStatus(server.url, "checking"); |
|||
|
|||
try { |
|||
const controller = new AbortController(); |
|||
const timeoutId = setTimeout(() => controller.abort(), 8000); |
|||
|
|||
// Try to get actual device info from fromradio endpoint
|
|||
const response = await fetch(`${server.url}/api/v1/fromradio?all=false`, { |
|||
method: "GET", |
|||
mode: "cors", |
|||
signal: controller.signal, |
|||
}); |
|||
|
|||
clearTimeout(timeoutId); |
|||
|
|||
if (response.ok) { |
|||
// Try to get basic device info if available
|
|||
try { |
|||
// First check if we can get a simple status response
|
|||
const statusResponse = await fetch(`${server.url}/api/v1/fromradio`, { |
|||
method: "GET", |
|||
mode: "cors", |
|||
signal: AbortSignal.timeout(3000), |
|||
}); |
|||
|
|||
if (statusResponse.ok) { |
|||
// For now, just mark as online - we could parse protobuf later for device details
|
|||
updateServerStatus(server.url, "online"); |
|||
|
|||
// TODO: Parse protobuf response to get device info
|
|||
// This would require proper protobuf decoding which is complex
|
|||
// For now, we'll show it as online and get device info after connection
|
|||
} else { |
|||
updateServerStatus(server.url, "offline"); |
|||
} |
|||
} catch { |
|||
// If fromradio fails, try the simpler status endpoint
|
|||
const fallbackResponse = await fetch(`${server.url}/`, { |
|||
method: "GET", |
|||
mode: "cors", |
|||
signal: AbortSignal.timeout(3000), |
|||
}); |
|||
updateServerStatus( |
|||
server.url, |
|||
fallbackResponse.ok ? "online" : "offline", |
|||
); |
|||
} |
|||
} else { |
|||
updateServerStatus(server.url, "offline"); |
|||
} |
|||
} catch { |
|||
updateServerStatus(server.url, "offline"); |
|||
} |
|||
}; |
|||
|
|||
const connectToServer = async (server: SavedServer) => { |
|||
setConnectingToServer(server.url); |
|||
setConnectionError(null); |
|||
|
|||
const [connectionError, setConnectionError] = useState< |
|||
{ host: string; secure: boolean } | null |
|||
>(null); |
|||
try { |
|||
const id = randId(); |
|||
const transport = await TransportHTTP.create( |
|||
server.host, |
|||
server.protocol === "https", |
|||
); |
|||
const device = addDevice(id); |
|||
const connection = new MeshDevice(transport, id); |
|||
connection.configure(); |
|||
setSelectedDevice(id); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection, messageStore); |
|||
|
|||
const onSubmit = handleSubmit(async (data) => { |
|||
// Update last used time
|
|||
addSavedServer(server.host, server.protocol); |
|||
updateServerStatus(server.url, "online"); |
|||
|
|||
closeDialog(); |
|||
} catch (error) { |
|||
console.error("Connection error:", error); |
|||
setConnectionError(`Failed to connect to ${server.host}`); |
|||
updateServerStatus(server.url, "offline"); |
|||
} finally { |
|||
setConnectingToServer(null); |
|||
} |
|||
}; |
|||
|
|||
const handleAddServer = handleSubmit(async (data) => { |
|||
setConnectionInProgress(true); |
|||
setConnectionError(null); |
|||
|
|||
try { |
|||
const protocol = data.secure ? "https" : "http"; |
|||
const id = randId(); |
|||
const transport = await TransportHTTP.create(data.ip, data.tls); |
|||
const transport = await TransportHTTP.create(data.hostname, data.secure); |
|||
const device = addDevice(id); |
|||
const connection = new MeshDevice(transport, id); |
|||
connection.configure(); |
|||
setSelectedDevice(id); |
|||
device.addConnection(connection); |
|||
subscribeAll(device, connection, messageStore); |
|||
|
|||
// Save to history
|
|||
addSavedServer(data.hostname, protocol); |
|||
|
|||
setAddServerOpen(false); |
|||
reset(); |
|||
closeDialog(); |
|||
} catch (error) { |
|||
console.error("Connection error:", error); |
|||
// Capture all connection errors regardless of type
|
|||
setConnectionError({ host: data.ip, secure: data.tls }); |
|||
setConnectionError(`Failed to connect to ${data.hostname}`); |
|||
} finally { |
|||
setConnectionInProgress(false); |
|||
} |
|||
}); |
|||
|
|||
const getStatusIcon = (status?: string) => { |
|||
switch (status) { |
|||
case "online": |
|||
return <Circle className="h-3 w-3 fill-green-500 text-green-500" />; |
|||
case "offline": |
|||
return <Circle className="h-3 w-3 fill-red-500 text-red-500" />; |
|||
case "checking": |
|||
return ( |
|||
<Circle className="h-3 w-3 fill-yellow-500 text-yellow-500 animate-spin" /> |
|||
); |
|||
default: |
|||
return <Circle className="h-3 w-3 fill-slate-300 text-slate-300" />; |
|||
} |
|||
}; |
|||
|
|||
const getSecurityIcon = (protocol: "http" | "https") => { |
|||
return protocol === "https" |
|||
? <Lock className="h-4 w-4 text-green-600" /> |
|||
: <LockOpen className="h-4 w-4 text-yellow-600" />; |
|||
}; |
|||
|
|||
const getStatusText = (status?: string) => { |
|||
switch (status) { |
|||
case "online": |
|||
return "Online"; |
|||
case "offline": |
|||
return "Offline"; |
|||
case "checking": |
|||
return "Checking..."; |
|||
default: |
|||
return "Unknown"; |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}> |
|||
<fieldset |
|||
className="flex flex-col gap-2" |
|||
<> |
|||
<div |
|||
className="flex w-full flex-col gap-3 p-4" |
|||
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> |
|||
{/* Server List Header */} |
|||
<div className="flex items-center justify-between"> |
|||
<div className="flex items-center gap-2"> |
|||
<Server className="h-4 w-4 text-slate-600 dark:text-slate-400" /> |
|||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"> |
|||
Meshtastic Servers |
|||
</span> |
|||
</div> |
|||
{savedServers.length > 0 && ( |
|||
<Button |
|||
type="button" |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={clearSavedServers} |
|||
className="h-7 px-2 text-xs text-red-600 hover:text-red-700" |
|||
> |
|||
<Trash2 className="h-3 w-3 mr-1" /> |
|||
Clear All |
|||
</Button> |
|||
)} |
|||
</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")} |
|||
{/* Server List */} |
|||
<div className="flex-1 space-y-2 max-h-48 overflow-y-auto"> |
|||
{savedServers.length === 0 |
|||
? ( |
|||
<div className="flex flex-col items-center justify-center py-8 text-center"> |
|||
<Server className="h-8 w-8 text-slate-300 mb-2" /> |
|||
<p className="text-sm text-slate-500 dark:text-slate-400"> |
|||
No saved servers yet |
|||
</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 className="text-xs text-slate-400 dark:text-slate-500"> |
|||
Add your first Meshtastic node to get started |
|||
</p> |
|||
</div> |
|||
) |
|||
: ( |
|||
savedServers.slice(0, 5).map((server) => ( |
|||
<div |
|||
key={server.url} |
|||
className="flex items-center gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" |
|||
> |
|||
{/* Status Indicator */} |
|||
<div className="flex-shrink-0"> |
|||
{getStatusIcon(server.status)} |
|||
</div> |
|||
|
|||
{/* Server Info */} |
|||
<div className="flex-1 min-w-0"> |
|||
<div className="flex items-center gap-2"> |
|||
<span className="font-medium text-slate-900 dark:text-slate-100 truncate"> |
|||
{server.host} |
|||
</span> |
|||
{getSecurityIcon(server.protocol)} |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mt-1"> |
|||
<span className="flex items-center gap-1"> |
|||
<span |
|||
className={`w-2 h-2 rounded-full ${ |
|||
server.status === "online" |
|||
? "bg-green-500" |
|||
: server.status === "offline" |
|||
? "bg-red-500" |
|||
: "bg-yellow-500" |
|||
}`}
|
|||
/> |
|||
{getStatusText(server.status)} |
|||
</span> |
|||
|
|||
{/* Device info if available */} |
|||
{server.deviceInfo?.model && ( |
|||
<> |
|||
<span>•</span> |
|||
<span className="font-mono text-xs bg-slate-200 dark:bg-slate-700 px-1 rounded"> |
|||
{server.deviceInfo.model} |
|||
</span> |
|||
</> |
|||
)} |
|||
|
|||
{server.deviceInfo?.nodeCount && ( |
|||
<> |
|||
<span>•</span> |
|||
<span className="flex items-center gap-1"> |
|||
<Users className="h-3 w-3" /> |
|||
{server.deviceInfo.nodeCount} |
|||
</span> |
|||
</> |
|||
)} |
|||
|
|||
{server.deviceInfo?.unreadCount && |
|||
server.deviceInfo.unreadCount > 0 && ( |
|||
<> |
|||
<span>•</span> |
|||
<span className="flex items-center gap-1 text-blue-600 dark:text-blue-400"> |
|||
<MessageCircle className="h-3 w-3" /> |
|||
{server.deviceInfo.unreadCount} |
|||
</span> |
|||
</> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Actions */} |
|||
<div className="flex items-center gap-1"> |
|||
<Button |
|||
variant="default" |
|||
size="sm" |
|||
onClick={() => connectToServer(server)} |
|||
disabled={connectingToServer === server.url} |
|||
className="h-8 px-3" |
|||
> |
|||
{connectingToServer === server.url |
|||
? <Clock className="h-3 w-3 mr-1 animate-spin" /> |
|||
: <Wifi className="h-3 w-3 mr-1" />} |
|||
Connect |
|||
</Button> |
|||
|
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => removeSavedServer(server.url)} |
|||
className="h-8 w-8 p-0 text-slate-400 hover:text-red-600" |
|||
> |
|||
<Trash2 className="h-3 w-3" /> |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
)) |
|||
)} |
|||
</div> |
|||
|
|||
{/* Add New Server Button */} |
|||
<Button |
|||
variant="outline" |
|||
onClick={() => setAddServerOpen(true)} |
|||
className="w-full h-10 border-dashed border-slate-300 dark:border-slate-600 hover:border-slate-400 dark:hover:border-slate-500" |
|||
> |
|||
<Plus className="h-4 w-4 mr-2" /> |
|||
Add New Server |
|||
</Button> |
|||
|
|||
{/* Connection Error */} |
|||
{connectionError && ( |
|||
<div className="p-3 rounded-md bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800"> |
|||
<div className="flex gap-2 items-start"> |
|||
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" /> |
|||
<p className="text-sm text-red-800 dark:text-red-200"> |
|||
{connectionError} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</fieldset> |
|||
<Button |
|||
type="submit" |
|||
variant="default" |
|||
> |
|||
<span> |
|||
{connectionInProgress |
|||
? t("newDeviceDialog.connecting") |
|||
: t("newDeviceDialog.connect")} |
|||
</span> |
|||
</Button> |
|||
</form> |
|||
</div> |
|||
|
|||
{/* Add Server Dialog */} |
|||
<Dialog open={addServerOpen} onOpenChange={setAddServerOpen}> |
|||
<DialogContent className="sm:max-w-md"> |
|||
<DialogHeader> |
|||
<DialogTitle className="flex items-center gap-2"> |
|||
<Plus className="h-4 w-4" /> |
|||
Add New Server |
|||
</DialogTitle> |
|||
</DialogHeader> |
|||
|
|||
<form onSubmit={handleAddServer} className="space-y-4"> |
|||
<div> |
|||
<Label htmlFor="hostname">Hostname or IP Address</Label> |
|||
<Input |
|||
id="hostname" |
|||
placeholder="meshtastic.local or 192.168.1.100" |
|||
className="mt-1" |
|||
{...register("hostname", { required: true })} |
|||
/> |
|||
</div> |
|||
|
|||
<div className="flex items-center gap-2"> |
|||
<Switch |
|||
checked={isURLHTTPS || secureValue} |
|||
onCheckedChange={(checked) => setValue("secure", checked)} |
|||
disabled={isURLHTTPS} |
|||
{...register("secure")} |
|||
/> |
|||
<Label className="flex items-center gap-2"> |
|||
{secureValue |
|||
? <Lock className="h-4 w-4 text-green-600" /> |
|||
: <LockOpen className="h-4 w-4 text-yellow-600" />} |
|||
Use HTTPS (Secure) |
|||
</Label> |
|||
</div> |
|||
|
|||
<div className="flex gap-2 pt-4"> |
|||
<Button |
|||
type="button" |
|||
variant="outline" |
|||
onClick={() => setAddServerOpen(false)} |
|||
className="flex-1" |
|||
> |
|||
Cancel |
|||
</Button> |
|||
<Button |
|||
type="submit" |
|||
disabled={connectionInProgress} |
|||
className="flex-1" |
|||
> |
|||
{connectionInProgress |
|||
? ( |
|||
<> |
|||
<Clock className="h-4 w-4 mr-2 animate-spin" /> |
|||
Connecting... |
|||
</> |
|||
) |
|||
: ( |
|||
<> |
|||
<Wifi className="h-4 w-4 mr-2" /> |
|||
Connect |
|||
</> |
|||
)} |
|||
</Button> |
|||
</div> |
|||
</form> |
|||
</DialogContent> |
|||
</Dialog> |
|||
</> |
|||
); |
|||
}; |
|||
|
|||
Loading…
Reference in new issue