You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
468 lines
16 KiB
468 lines
16 KiB
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
|
|
import { Button } from "@components/UI/Button.tsx";
|
|
import { Input } from "@components/UI/Input.tsx";
|
|
import { Label } from "@components/UI/Label.tsx";
|
|
import { Switch } from "@components/UI/Switch.tsx";
|
|
import {
|
|
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 { 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 type { SavedServer } from "@core/stores/appStore.ts";
|
|
|
|
interface AddServerFormData {
|
|
hostname: string;
|
|
secure: boolean;
|
|
}
|
|
|
|
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,
|
|
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);
|
|
|
|
// 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);
|
|
|
|
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);
|
|
|
|
// 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.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);
|
|
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="flex w-full flex-col gap-3 p-4"
|
|
style={{ minHeight: "12rem" }}
|
|
>
|
|
{/* 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>
|
|
|
|
{/* 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 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>
|
|
)}
|
|
</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>
|
|
</>
|
|
);
|
|
};
|
|
|