mirror of
https://github.com/dpup/meshstream.git
synced 2026-03-28 17:42:37 +01:00
Info page
This commit is contained in:
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Daniel Pupius
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
10
main.go
10
main.go
@@ -226,10 +226,12 @@ func main() {
|
||||
|
||||
// Start the web server
|
||||
webServer := server.New(server.Config{
|
||||
Host: config.ServerHost,
|
||||
Port: config.ServerPort,
|
||||
Broker: broker,
|
||||
Logger: logger,
|
||||
Host: config.ServerHost,
|
||||
Port: config.ServerPort,
|
||||
Broker: broker,
|
||||
Logger: logger,
|
||||
MQTTServer: config.MQTTBroker,
|
||||
MQTTTopicPath: config.MQTTTopicPrefix + "/#",
|
||||
})
|
||||
|
||||
// Start the server in a goroutine
|
||||
|
||||
@@ -17,10 +17,21 @@ import (
|
||||
|
||||
// Config holds server configuration
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
Logger logging.Logger
|
||||
Broker *mqtt.Broker // The MQTT message broker
|
||||
Host string
|
||||
Port string
|
||||
Logger logging.Logger
|
||||
Broker *mqtt.Broker // The MQTT message broker
|
||||
MQTTServer string // MQTT server hostname
|
||||
MQTTTopicPath string // MQTT topic path being subscribed to
|
||||
}
|
||||
|
||||
// Create connection info JSON to send to the client
|
||||
type ConnectionInfo struct {
|
||||
Message string `json:"message"`
|
||||
MQTTServer string `json:"mqttServer"`
|
||||
MQTTTopic string `json:"mqttTopic"`
|
||||
Connected bool `json:"connected"`
|
||||
ServerTime int64 `json:"serverTime"`
|
||||
}
|
||||
|
||||
// Server encapsulates the HTTP server functionality
|
||||
@@ -144,19 +155,34 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
heartbeatTicker := time.NewTicker(30 * time.Second)
|
||||
defer heartbeatTicker.Stop()
|
||||
|
||||
// Send an initial message with an additional 1.5k payload. This force buffer
|
||||
// flush so the client knows the connection is open.
|
||||
// Send an initial message with connection information and an additional padding payload.
|
||||
// This forces buffer flush so the client knows the connection is open.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
initialMessage := "Connected to stream"
|
||||
connectionInfo := ConnectionInfo{
|
||||
Message: "Connected to stream",
|
||||
MQTTServer: s.config.MQTTServer,
|
||||
MQTTTopic: s.config.MQTTTopicPath,
|
||||
Connected: true,
|
||||
ServerTime: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Convert to JSON
|
||||
infoJson, err := json.Marshal(connectionInfo)
|
||||
if err != nil {
|
||||
logger.Errorw("Failed to marshal connection info", "error", err)
|
||||
infoJson = []byte(`{"message":"Connected to stream"}`)
|
||||
}
|
||||
|
||||
// Create padding for buffer flush
|
||||
paddingSize := 1500
|
||||
padding := make([]byte, paddingSize)
|
||||
for i := 0; i < paddingSize; i++ {
|
||||
padding[i] = byte('A' + (i % 26))
|
||||
}
|
||||
|
||||
// Send the event with the padded data
|
||||
fmt.Fprintf(w, "event: info\ndata: %s\n\n", initialMessage)
|
||||
// Send the event with connection info and padded data
|
||||
fmt.Fprintf(w, "event: connection_info\ndata: %s\n\n", infoJson)
|
||||
fmt.Fprintf(w, "event: padding\ndata: %s\n\n", padding)
|
||||
flusher.Flush()
|
||||
|
||||
|
||||
47
web/src/components/MqttConnectionInfo.tsx
Normal file
47
web/src/components/MqttConnectionInfo.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { useAppSelector } from "../hooks";
|
||||
|
||||
/**
|
||||
* Component that displays MQTT connection details
|
||||
*/
|
||||
export const MqttConnectionInfo: React.FC = () => {
|
||||
const connectionInfo = useAppSelector(state => state.connection.info);
|
||||
|
||||
if (!connectionInfo) {
|
||||
return (
|
||||
<div className="text-neutral-400 italic mb-4">
|
||||
Waiting for connection information...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{connectionInfo.mqttServer && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-200 mb-1">MQTT Server</h3>
|
||||
<div className="bg-neutral-900/50 p-2 rounded font-mono text-xs text-neutral-300 overflow-auto">
|
||||
{connectionInfo.mqttServer}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionInfo.mqttTopic && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-200 mb-1">MQTT Topic</h3>
|
||||
<div className="bg-neutral-900/50 p-2 rounded font-mono text-xs text-neutral-300 break-all">
|
||||
{connectionInfo.mqttTopic}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-200 mb-1">Connection Status</h3>
|
||||
<div className={`flex items-center ${connectionInfo.connected ? 'text-green-500' : 'text-red-500'}`}>
|
||||
<div className={`h-2 w-2 rounded-full mr-2 ${connectionInfo.connected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span>{connectionInfo.connected ? 'Connected' : 'Disconnected'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,6 +17,18 @@ export interface ApiResponse<T> {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Connection info event type
|
||||
export interface ConnectionInfoEvent {
|
||||
type: "connection_info";
|
||||
data: {
|
||||
mqttServer: string;
|
||||
mqttTopic: string;
|
||||
connected: boolean;
|
||||
serverTime?: number;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
InfoEvent,
|
||||
@@ -78,6 +90,7 @@ export function streamPackets(
|
||||
// Remove all event listeners
|
||||
source.removeEventListener("message", handleMessage as EventListener);
|
||||
source.removeEventListener("info", handleInfo as EventListener);
|
||||
source.removeEventListener("connection_info", handleConnectionInfo as EventListener);
|
||||
source.onerror = null;
|
||||
|
||||
// Close the connection
|
||||
@@ -103,6 +116,25 @@ export function streamPackets(
|
||||
data: String(evtData),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle connection info events
|
||||
*/
|
||||
function handleConnectionInfo(event: Event): void {
|
||||
const evtData = (event as any).data;
|
||||
try {
|
||||
// Parse the connection info JSON
|
||||
const parsedData = JSON.parse(String(evtData));
|
||||
|
||||
// Forward the connection info to the caller
|
||||
onEvent({
|
||||
type: "connection_info",
|
||||
data: parsedData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[SSE] Failed to parse connection info:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle message events
|
||||
@@ -185,6 +217,7 @@ export function streamPackets(
|
||||
// Set up event handlers
|
||||
source.addEventListener("info", handleInfo as EventListener);
|
||||
source.addEventListener("message", handleMessage as EventListener);
|
||||
source.addEventListener("connection_info", handleConnectionInfo as EventListener);
|
||||
source.onerror = handleError;
|
||||
} catch (error) {
|
||||
console.error("[SSE] Failed to create EventSource:", error);
|
||||
|
||||
@@ -423,10 +423,22 @@ export interface PaddingEvent {
|
||||
data: string; // Random data to trigger a flush.
|
||||
}
|
||||
|
||||
export interface ConnectionInfoEvent {
|
||||
type: "connection_info";
|
||||
data: {
|
||||
mqttServer: string;
|
||||
mqttTopic: string;
|
||||
connected: boolean;
|
||||
serverTime?: number;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| InfoEvent
|
||||
| MessageEvent
|
||||
| PaddingEvent
|
||||
| BadDataEvent;
|
||||
| BadDataEvent
|
||||
| ConnectionInfoEvent;
|
||||
|
||||
export type StreamEventHandler = (event: StreamEvent) => void;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as StreamImport } from './routes/stream'
|
||||
import { Route as RootImport } from './routes/root'
|
||||
import { Route as MapImport } from './routes/map'
|
||||
import { Route as InfoImport } from './routes/info'
|
||||
import { Route as HomeImport } from './routes/home'
|
||||
import { Route as DemoImport } from './routes/demo'
|
||||
import { Route as ChannelsImport } from './routes/channels'
|
||||
@@ -41,6 +42,12 @@ const MapRoute = MapImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const InfoRoute = InfoImport.update({
|
||||
id: '/info',
|
||||
path: '/info',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const HomeRoute = HomeImport.update({
|
||||
id: '/home',
|
||||
path: '/home',
|
||||
@@ -109,6 +116,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof HomeImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/info': {
|
||||
id: '/info'
|
||||
path: '/info'
|
||||
fullPath: '/info'
|
||||
preLoaderRoute: typeof InfoImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/map': {
|
||||
id: '/map'
|
||||
path: '/map'
|
||||
@@ -154,6 +168,7 @@ export interface FileRoutesByFullPath {
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/info': typeof InfoRoute
|
||||
'/map': typeof MapRoute
|
||||
'/root': typeof RootRoute
|
||||
'/stream': typeof StreamRoute
|
||||
@@ -166,6 +181,7 @@ export interface FileRoutesByTo {
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/info': typeof InfoRoute
|
||||
'/map': typeof MapRoute
|
||||
'/root': typeof RootRoute
|
||||
'/stream': typeof StreamRoute
|
||||
@@ -179,6 +195,7 @@ export interface FileRoutesById {
|
||||
'/channels': typeof ChannelsRoute
|
||||
'/demo': typeof DemoRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/info': typeof InfoRoute
|
||||
'/map': typeof MapRoute
|
||||
'/root': typeof RootRoute
|
||||
'/stream': typeof StreamRoute
|
||||
@@ -193,6 +210,7 @@ export interface FileRouteTypes {
|
||||
| '/channels'
|
||||
| '/demo'
|
||||
| '/home'
|
||||
| '/info'
|
||||
| '/map'
|
||||
| '/root'
|
||||
| '/stream'
|
||||
@@ -204,6 +222,7 @@ export interface FileRouteTypes {
|
||||
| '/channels'
|
||||
| '/demo'
|
||||
| '/home'
|
||||
| '/info'
|
||||
| '/map'
|
||||
| '/root'
|
||||
| '/stream'
|
||||
@@ -215,6 +234,7 @@ export interface FileRouteTypes {
|
||||
| '/channels'
|
||||
| '/demo'
|
||||
| '/home'
|
||||
| '/info'
|
||||
| '/map'
|
||||
| '/root'
|
||||
| '/stream'
|
||||
@@ -228,6 +248,7 @@ export interface RootRouteChildren {
|
||||
ChannelsRoute: typeof ChannelsRoute
|
||||
DemoRoute: typeof DemoRoute
|
||||
HomeRoute: typeof HomeRoute
|
||||
InfoRoute: typeof InfoRoute
|
||||
MapRoute: typeof MapRoute
|
||||
RootRoute: typeof RootRoute
|
||||
StreamRoute: typeof StreamRoute
|
||||
@@ -240,6 +261,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ChannelsRoute: ChannelsRoute,
|
||||
DemoRoute: DemoRoute,
|
||||
HomeRoute: HomeRoute,
|
||||
InfoRoute: InfoRoute,
|
||||
MapRoute: MapRoute,
|
||||
RootRoute: RootRoute,
|
||||
StreamRoute: StreamRoute,
|
||||
@@ -261,6 +283,7 @@ export const routeTree = rootRoute
|
||||
"/channels",
|
||||
"/demo",
|
||||
"/home",
|
||||
"/info",
|
||||
"/map",
|
||||
"/root",
|
||||
"/stream",
|
||||
@@ -280,6 +303,9 @@ export const routeTree = rootRoute
|
||||
"/home": {
|
||||
"filePath": "home.tsx"
|
||||
},
|
||||
"/info": {
|
||||
"filePath": "info.tsx"
|
||||
},
|
||||
"/map": {
|
||||
"filePath": "map.tsx"
|
||||
},
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Outlet } from "@tanstack/react-router";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAppDispatch } from "../hooks";
|
||||
import { Nav } from "../components";
|
||||
import { streamPackets, StreamEvent } from "../lib/api";
|
||||
import { streamPackets, StreamEvent, ConnectionInfoEvent } from "../lib/api";
|
||||
import { processNewPacket } from "../store/slices/aggregatorSlice";
|
||||
import { addPacket } from "../store/slices/packetSlice";
|
||||
import { updateConnectionInfo, updateConnectionStatus } from "../store/slices/connectionSlice";
|
||||
import { createRootRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
@@ -38,7 +39,19 @@ function RootLayout() {
|
||||
console.log("[SSE] Connection restored successfully");
|
||||
connectionAttemptsRef.current = 0;
|
||||
isReconnectingRef.current = false;
|
||||
|
||||
// Update connection status in Redux
|
||||
dispatch(updateConnectionStatus(true));
|
||||
}
|
||||
} else if (event.type === "connection_info") {
|
||||
// Handle connection info events
|
||||
console.log("[SSE] Connection info received:", event.data);
|
||||
|
||||
// Update connection info in Redux
|
||||
dispatch(updateConnectionInfo((event as ConnectionInfoEvent).data));
|
||||
|
||||
// Update UI connection status
|
||||
setConnectionStatus(`Connected to ${event.data.mqttServer}`);
|
||||
} else if (event.type === "message") {
|
||||
// Process message for both the aggregator and packet display
|
||||
dispatch(processNewPacket(event.data));
|
||||
@@ -53,6 +66,9 @@ function RootLayout() {
|
||||
setConnectionStatus("Connection error. Reconnecting...");
|
||||
isReconnectingRef.current = true;
|
||||
|
||||
// Update connection status in Redux
|
||||
dispatch(updateConnectionStatus(false));
|
||||
|
||||
// Increment connection attempts for UI tracking
|
||||
connectionAttemptsRef.current += 1;
|
||||
console.log(
|
||||
|
||||
90
web/src/routes/info.tsx
Normal file
90
web/src/routes/info.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { PageWrapper } from "../components";
|
||||
import { SITE_TITLE, SITE_DESCRIPTION } from "../lib/config";
|
||||
import { ExternalLink, Github } from "lucide-react";
|
||||
import { MqttConnectionInfo } from "../components/MqttConnectionInfo";
|
||||
|
||||
export const Route = createFileRoute("/info")({
|
||||
component: InfoPage,
|
||||
});
|
||||
|
||||
function InfoPage() {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="max-w-3xl lg:p-12">
|
||||
<h1 className="text-2xl font-semibold mb-4 text-neutral-100">{SITE_TITLE}</h1>
|
||||
<p className="text-neutral-400 mb-6">{SITE_DESCRIPTION}</p>
|
||||
|
||||
<InfoSection title="About this site">
|
||||
<p className="text-neutral-400 mb-4">
|
||||
This site provides real-time visualization and monitoring of the Meshtastic network via MQTT.
|
||||
It allows you to view node positions, track message traffic, and monitor the status of devices in the mesh.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center mb-6">
|
||||
<a
|
||||
href="https://meshtastic.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-pink-400 hover:text-pink-300 inline-flex items-center transition-colors"
|
||||
>
|
||||
Learn more about Meshtastic
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2 text-neutral-100">What is Meshtastic?</h3>
|
||||
<p className="text-neutral-400 mb-4">
|
||||
Meshtastic is an open source, off-grid, decentralized mesh networking platform that allows
|
||||
for text messaging, GPS location sharing, and data transmission without the need for cellular
|
||||
service or internet access. It uses affordable, low-power radio devices based on LoRa technology.
|
||||
</p>
|
||||
</InfoSection>
|
||||
|
||||
<InfoSection title="MQTT Connection Details">
|
||||
<MqttConnectionInfo />
|
||||
</InfoSection>
|
||||
|
||||
<InfoSection title="Privacy Considerations">
|
||||
<p className="text-neutral-400 mb-2">
|
||||
Position data shown on this dashboard may have reduced precision for privacy reasons,
|
||||
depending on the configuration of the Meshtastic network and MQTT server.
|
||||
</p>
|
||||
<p className="text-neutral-400">
|
||||
This dashboard only displays information that has been explicitly shared via the
|
||||
Meshtastic mesh network and relayed through an MQTT gateway.
|
||||
</p>
|
||||
</InfoSection>
|
||||
|
||||
<InfoSection title="Source Code & License">
|
||||
<div className="flex items-center mb-3">
|
||||
<a
|
||||
href="https://github.com/dpup/meshstream"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-pink-400 hover:text-pink-300 inline-flex items-center transition-colors"
|
||||
>
|
||||
<Github className="mr-2 h-5 w-5" />
|
||||
github.com/dpup/meshstream
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-neutral-400">
|
||||
Meshstream is open source software licensed under the MIT License. You are free to use,
|
||||
modify, and distribute this software according to the terms of the license.
|
||||
</p>
|
||||
</InfoSection>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function InfoSection({ title, children } : { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-neutral-700/40 rounded-lg p-6 mb-6 effect-inset">
|
||||
<h2 className="text-xl font-semibold mb-3 text-neutral-100">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ function RootComponent() {
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/packets"
|
||||
to="/stream"
|
||||
className="hover:underline"
|
||||
activeProps={{
|
||||
className: "font-bold underline",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import packetReducer from './slices/packetSlice';
|
||||
import aggregatorReducer from './slices/aggregatorSlice';
|
||||
import connectionReducer from './slices/connectionSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
packets: packetReducer,
|
||||
aggregator: aggregatorReducer,
|
||||
connection: connectionReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
54
web/src/store/slices/connectionSlice.ts
Normal file
54
web/src/store/slices/connectionSlice.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
// Interface for MQTT connection information
|
||||
export interface ConnectionInfo {
|
||||
mqttServer: string;
|
||||
mqttTopic: string;
|
||||
connected: boolean;
|
||||
serverTime?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// State for the connection slice
|
||||
interface ConnectionState {
|
||||
info: ConnectionInfo | null;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: ConnectionState = {
|
||||
info: null,
|
||||
};
|
||||
|
||||
// Create the connection slice
|
||||
const connectionSlice = createSlice({
|
||||
name: "connection",
|
||||
initialState,
|
||||
reducers: {
|
||||
// Update connection information
|
||||
updateConnectionInfo(state, action: PayloadAction<ConnectionInfo>) {
|
||||
state.info = action.payload;
|
||||
},
|
||||
|
||||
// Reset connection information
|
||||
resetConnectionInfo(state) {
|
||||
state.info = null;
|
||||
},
|
||||
|
||||
// Update connection status only
|
||||
updateConnectionStatus(state, action: PayloadAction<boolean>) {
|
||||
if (state.info) {
|
||||
state.info.connected = action.payload;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Export actions
|
||||
export const {
|
||||
updateConnectionInfo,
|
||||
resetConnectionInfo,
|
||||
updateConnectionStatus,
|
||||
} = connectionSlice.actions;
|
||||
|
||||
// Export reducer
|
||||
export default connectionSlice.reducer;
|
||||
Reference in New Issue
Block a user