mirror of
https://github.com/dpup/meshstream.git
synced 2026-08-06 16:53:09 +02:00
Initial skeleton for web application
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# Binaries and build artifacts
|
||||
/dist/
|
||||
/web/dist/
|
||||
/bin/
|
||||
|
||||
# Go specific
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
.PHONY: build run gen-proto clean tools
|
||||
.PHONY: build run gen-proto clean tools web-run web-build web-test web-lint
|
||||
|
||||
ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
|
||||
|
||||
# Build directories
|
||||
BIN_DIR := bin
|
||||
TOOLS_DIR := $(BIN_DIR)/tools
|
||||
WEB_DIR := $(ROOT_DIR)/web
|
||||
WEB_DIST_DIR := $(ROOT_DIR)/dist/static
|
||||
|
||||
# Proto compilation
|
||||
PROTOC_GEN_GO := $(TOOLS_DIR)/protoc-gen-go
|
||||
@@ -43,4 +45,23 @@ $(TOOLS_DIR):
|
||||
|
||||
# Install the protoc-gen-go tool
|
||||
$(PROTOC_GEN_GO): $(TOOLS_DIR)
|
||||
GOBIN=$(abspath $(TOOLS_DIR)) go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
GOBIN=$(abspath $(TOOLS_DIR)) go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
|
||||
# Web application commands
|
||||
# Run the web application in development mode
|
||||
web-run:
|
||||
cd $(WEB_DIR) && pnpm dev
|
||||
|
||||
# Build the web application for production
|
||||
web-build:
|
||||
cd $(WEB_DIR) && pnpm build
|
||||
mkdir -p $(WEB_DIST_DIR)
|
||||
cp -r $(WEB_DIR)/dist/* $(WEB_DIST_DIR)/
|
||||
|
||||
# Run tests for the web application
|
||||
web-test:
|
||||
cd $(WEB_DIR) && pnpm test
|
||||
|
||||
# Run linting for the web application
|
||||
web-lint:
|
||||
cd $(WEB_DIR) && pnpm lint
|
||||
+125
-27
@@ -1,36 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meshtastic Stream</title>
|
||||
<meta name="description" content="Meshstream - A web interface for viewing Meshtastic network traffic">
|
||||
<title>Meshstream</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
padding: 40px 20px;
|
||||
line-height: 1.6;
|
||||
max-width: 960px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
background-color: #f9fafb;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 90vh;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 10px;
|
||||
color: #2563eb;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
margin: 0 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#messages {
|
||||
margin-top: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
height: 500px;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
background-color: #f9f9f9;
|
||||
background-color: #ffffff;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.message {
|
||||
@@ -42,28 +88,81 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: 10px;
|
||||
background-color: #e9f7fe;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
.cta {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 2rem;
|
||||
font-size: 0.9rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Meshtastic Stream</h1>
|
||||
|
||||
<div class="info">
|
||||
<p>This page displays real-time messages from Meshtastic nodes via MQTT.</p>
|
||||
<p>Messages are streamed using Server-Sent Events (SSE) and will appear below as they arrive.</p>
|
||||
</div>
|
||||
|
||||
<div id="messages">
|
||||
<p>Waiting for messages...</p>
|
||||
<div class="container">
|
||||
<h1>Meshstream</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="showView('simple')">Simple View</div>
|
||||
<div class="tab" onclick="showView('dev')">Development</div>
|
||||
</div>
|
||||
|
||||
<div id="simple-view" class="view active">
|
||||
<p>This page displays real-time messages from Meshtastic nodes via MQTT.</p>
|
||||
<div id="messages">
|
||||
<p>Waiting for messages...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dev-view" class="view">
|
||||
<p>
|
||||
A web interface for viewing Meshtastic network traffic.
|
||||
This is a placeholder page for the production build.
|
||||
</p>
|
||||
<a href="http://localhost:3000" class="cta">Open Development Server</a>
|
||||
<p class="note">
|
||||
For production deployment, build the web application with <code>make web-build</code>
|
||||
to replace this placeholder with the full application.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showView(viewName) {
|
||||
// Hide all views
|
||||
document.querySelectorAll('.view').forEach(view => {
|
||||
view.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected view
|
||||
document.getElementById(viewName + '-view').classList.add('active');
|
||||
|
||||
// Update tab styling
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
// Find and activate the clicked tab
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
if (tab.textContent.toLowerCase().includes(viewName)) {
|
||||
tab.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const messagesDiv = document.getElementById('messages');
|
||||
|
||||
@@ -126,5 +225,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
# Development environment variables
|
||||
VITE_API_BASE_URL="http://localhost:8080"
|
||||
VITE_APP_ENV="development"
|
||||
@@ -0,0 +1,3 @@
|
||||
# Production environment variables
|
||||
VITE_API_BASE_URL=""
|
||||
VITE_APP_ENV="production"
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": { "browser": true, "es2020": true },
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"ignorePatterns": ["dist", ".eslintrc.cjs"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["react-refresh"],
|
||||
"rules": {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ "allowConstantExport": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Meshstream Web Interface
|
||||
|
||||
This is the web interface for the Meshstream application, which provides a real-time view of Meshtastic network traffic.
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- Vite
|
||||
- React
|
||||
- TypeScript
|
||||
- Redux Toolkit
|
||||
- Tailwind CSS
|
||||
- Tanstack Router
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start development server
|
||||
pnpm dev
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
|
||||
# Preview production build
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/components/` - React components
|
||||
- `src/routes/` - Tanstack Router route components
|
||||
- `src/store/` - Redux store and slices
|
||||
- `src/hooks/` - Custom React hooks
|
||||
- `src/lib/` - Utility functions and API clients
|
||||
- `src/styles/` - CSS styles
|
||||
- `src/assets/` - Static assets like images
|
||||
|
||||
## API
|
||||
|
||||
The application communicates with the Meshstream server via:
|
||||
|
||||
- REST API endpoints at `/api/...`
|
||||
- Server-Sent Events (SSE) connection at `/api/stream`
|
||||
|
||||
See `src/lib/api.ts` for more details on the API client implementation.
|
||||
@@ -0,0 +1,31 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import eslintPluginReact from 'eslint-plugin-react';
|
||||
import eslintPluginReactHooks from 'eslint-plugin-react-hooks';
|
||||
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{ ignores: ['**/*.js', 'dist/**/*', 'node_modules/**/*'] },
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'react': eslintPluginReact,
|
||||
'react-hooks': eslintPluginReactHooks,
|
||||
'react-refresh': eslintPluginReactRefresh,
|
||||
},
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Meshstream - A web interface for viewing Meshtastic network traffic" />
|
||||
<title>Meshstream</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+45
-4
@@ -1,11 +1,16 @@
|
||||
{
|
||||
"name": "meshstream-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Web application for visualizing Meshtastic network data",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "echo \"Error: no start script specified\" && exit 1"
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint --ignore-pattern 'dist/*' src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [
|
||||
"meshtastic",
|
||||
@@ -14,5 +19,41 @@
|
||||
"visualization"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.7.0",
|
||||
"@tailwindcss/postcss": "^4.1.4",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@tanstack/react-query-devtools": "^5.74.6",
|
||||
"@tanstack/react-router": "^1.116.0",
|
||||
"@tanstack/router-devtools": "^1.116.0",
|
||||
"@tanstack/router-vite-plugin": "^1.116.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-redux": "^9.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/leaflet": "^1.9.17",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
||||
"@typescript-eslint/parser": "^8.31.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.5.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.31.0",
|
||||
"vite": "^6.3.2",
|
||||
"vitest": "^3.1.2"
|
||||
}
|
||||
}
|
||||
Generated
+4367
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FilterProps {
|
||||
onChange: (filter: string) => void;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const Filter: React.FC<FilterProps> = ({ onChange, value }) => {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label htmlFor="filter" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Filter Packets
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="filter"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Filter by type, sender, etc."
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
interface MessageDisplayProps {
|
||||
message: any; // Will be properly typed once we have the protobuf structures
|
||||
}
|
||||
|
||||
export const MessageDisplay: React.FC<MessageDisplayProps> = ({ message }) => {
|
||||
// The data structure will be refined as we integrate with the protobuf definitions
|
||||
return (
|
||||
<div className="p-4 border rounded shadow-sm bg-white">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="font-medium">{message.from || 'Unknown'}</span>
|
||||
<span className="text-gray-500 text-sm">
|
||||
{message.timestamp ? new Date(message.timestamp).toLocaleString() : 'No timestamp'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
{message.text || 'No content'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
ID: {message.id || 'No ID'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PacketDetailsProps {
|
||||
packet: any; // Will be properly typed once we have the protobuf structures
|
||||
}
|
||||
|
||||
export const PacketDetails: React.FC<PacketDetailsProps> = ({ packet }) => {
|
||||
return (
|
||||
<div className="p-4 border rounded bg-gray-50">
|
||||
<h3 className="text-lg font-semibold mb-2">Packet Details</h3>
|
||||
<pre className="text-xs overflow-auto p-2 bg-gray-100 rounded">
|
||||
{JSON.stringify(packet, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PacketList } from './PacketList';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import packetReducer from '../store/slices/packetSlice';
|
||||
|
||||
describe('PacketList', () => {
|
||||
it('renders the empty state correctly', () => {
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
packets: packetReducer,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<PacketList />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('No packets received yet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '../hooks';
|
||||
|
||||
export const PacketList: React.FC = () => {
|
||||
const { packets, loading, error } = useAppSelector(state => state.packets);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4">Loading...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-4 text-red-500">Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (packets.length === 0) {
|
||||
return <div className="p-4">No packets received yet</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl font-bold mb-4">Received Packets</h2>
|
||||
<ul className="space-y-2">
|
||||
{packets.map(packet => (
|
||||
<li key={packet.id} className="p-2 border rounded">
|
||||
{packet.id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './PacketList';
|
||||
export * from './MessageDisplay';
|
||||
export * from './PacketDetails';
|
||||
export * from './Filter';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './useAppDispatch';
|
||||
export * from './useAppSelector';
|
||||
@@ -0,0 +1,4 @@
|
||||
import { useDispatch } from 'react-redux';
|
||||
import type { AppDispatch } from '../store';
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
@@ -0,0 +1,4 @@
|
||||
import { useSelector, TypedUseSelectorHook } from 'react-redux';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* API client functions for interacting with the Meshstream server
|
||||
*/
|
||||
import { API_ENDPOINTS } from './config';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a list of the most recent packets from the server
|
||||
*/
|
||||
export async function fetchRecentPackets(): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
const response = await fetch(API_ENDPOINTS.RECENT_PACKETS);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type definitions for SSE events
|
||||
*/
|
||||
export interface InfoEvent {
|
||||
type: 'info';
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface MessageEvent {
|
||||
type: 'message';
|
||||
data: any; // Will be the parsed JSON message
|
||||
}
|
||||
|
||||
export type StreamEvent = InfoEvent | MessageEvent;
|
||||
|
||||
export type StreamEventHandler = (event: StreamEvent) => void;
|
||||
|
||||
/**
|
||||
* Establish a Server-Sent Events connection to receive real-time packets
|
||||
*/
|
||||
export function streamPackets(
|
||||
onEvent: StreamEventHandler,
|
||||
onError?: (error: Event) => void
|
||||
): () => void {
|
||||
const evtSource = new EventSource(API_ENDPOINTS.STREAM);
|
||||
|
||||
// Handle general messages (fallback)
|
||||
evtSource.onmessage = (event) => {
|
||||
handleEventData('message', event.data, onEvent);
|
||||
};
|
||||
|
||||
// Handle info events specifically
|
||||
evtSource.addEventListener('info', (event) => {
|
||||
// Info events are just strings
|
||||
onEvent({
|
||||
type: 'info',
|
||||
data: event.data
|
||||
});
|
||||
});
|
||||
|
||||
// Handle message events specifically
|
||||
evtSource.addEventListener('message', (event) => {
|
||||
handleEventData('message', event.data, onEvent);
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
if (onError) {
|
||||
evtSource.onerror = onError;
|
||||
} else {
|
||||
evtSource.onerror = () => {
|
||||
console.error('EventSource failed');
|
||||
evtSource.close();
|
||||
};
|
||||
}
|
||||
|
||||
// Return cleanup function
|
||||
return () => evtSource.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to handle event data based on type
|
||||
*/
|
||||
function handleEventData(
|
||||
type: 'info' | 'message',
|
||||
data: string,
|
||||
callback: StreamEventHandler
|
||||
): void {
|
||||
try {
|
||||
if (type === 'info') {
|
||||
// Info events are plain text
|
||||
callback({
|
||||
type: 'info',
|
||||
data
|
||||
});
|
||||
} else {
|
||||
// Message events are JSON
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
callback({
|
||||
type: 'message',
|
||||
data: parsedData
|
||||
});
|
||||
} catch (error) {
|
||||
// If JSON parsing fails, treat it as a plain text message
|
||||
console.warn('Failed to parse message as JSON:', error);
|
||||
callback({
|
||||
type: 'info',
|
||||
data
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling event data:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Application configuration, pulling from environment variables
|
||||
*/
|
||||
|
||||
// Environment type
|
||||
export const IS_DEV = import.meta.env.DEV;
|
||||
export const IS_PROD = import.meta.env.PROD;
|
||||
export const APP_ENV = import.meta.env.VITE_APP_ENV || 'development';
|
||||
|
||||
// API URL configuration
|
||||
const getApiBaseUrl = (): string => {
|
||||
// In production, use the same domain (empty string base URL)
|
||||
if (IS_PROD) {
|
||||
return import.meta.env.VITE_API_BASE_URL || '';
|
||||
}
|
||||
|
||||
// In development, use the configured base URL with fallback
|
||||
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
// API endpoints
|
||||
export const API_ENDPOINTS = {
|
||||
STREAM: `${API_BASE_URL}/api/stream`,
|
||||
RECENT_PACKETS: `${API_BASE_URL}/api/packets/recent`,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './store';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { router } from './routes';
|
||||
import './styles/index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<RouterProvider router={router} />
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,157 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as RootImport } from './routes/root'
|
||||
import { Route as PacketsImport } from './routes/packets'
|
||||
import { Route as HomeImport } from './routes/home'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const RootRoute = RootImport.update({
|
||||
id: '/root',
|
||||
path: '/root',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const PacketsRoute = PacketsImport.update({
|
||||
id: '/packets',
|
||||
path: '/packets',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const HomeRoute = HomeImport.update({
|
||||
id: '/home',
|
||||
path: '/home',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/home': {
|
||||
id: '/home'
|
||||
path: '/home'
|
||||
fullPath: '/home'
|
||||
preLoaderRoute: typeof HomeImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/packets': {
|
||||
id: '/packets'
|
||||
path: '/packets'
|
||||
fullPath: '/packets'
|
||||
preLoaderRoute: typeof PacketsImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/root': {
|
||||
id: '/root'
|
||||
path: '/root'
|
||||
fullPath: '/root'
|
||||
preLoaderRoute: typeof RootImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/home': typeof HomeRoute
|
||||
'/packets': typeof PacketsRoute
|
||||
'/root': typeof RootRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/home' | '/packets' | '/root'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/home' | '/packets' | '/root'
|
||||
id: '__root__' | '/' | '/home' | '/packets' | '/root'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
HomeRoute: typeof HomeRoute
|
||||
PacketsRoute: typeof PacketsRoute
|
||||
RootRoute: typeof RootRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
HomeRoute: HomeRoute,
|
||||
PacketsRoute: PacketsRoute,
|
||||
RootRoute: RootRoute,
|
||||
}
|
||||
|
||||
export const routeTree = rootRoute
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
/* ROUTE_MANIFEST_START
|
||||
{
|
||||
"routes": {
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/home",
|
||||
"/packets",
|
||||
"/root"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.ts"
|
||||
},
|
||||
"/home": {
|
||||
"filePath": "home.tsx"
|
||||
},
|
||||
"/packets": {
|
||||
"filePath": "packets.tsx"
|
||||
},
|
||||
"/root": {
|
||||
"filePath": "root.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
ROUTE_MANIFEST_END */
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link, Outlet } from '@tanstack/react-router';
|
||||
|
||||
export default function Root() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<header className="bg-blue-600 text-white shadow-md">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Meshstream</h1>
|
||||
<nav className="space-x-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="hover:underline"
|
||||
activeProps={{
|
||||
className: 'font-bold underline',
|
||||
}}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/packets"
|
||||
className="hover:underline"
|
||||
activeProps={{
|
||||
className: 'font-bold underline',
|
||||
}}
|
||||
>
|
||||
Packets
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function IndexPage() {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Welcome to Meshstream</h2>
|
||||
<p className="mb-4">
|
||||
This application provides a real-time view of Meshtastic network traffic.
|
||||
</p>
|
||||
<div className="bg-blue-100 p-4 rounded">
|
||||
<h3 className="text-lg font-semibold mb-2">Getting Started</h3>
|
||||
<p>
|
||||
Click on the <strong>Packets</strong> link in the navigation to view incoming
|
||||
messages from the Meshtastic network.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Router, Route, RootRoute } from '@tanstack/react-router';
|
||||
import Root from './__root';
|
||||
import { IndexPage } from './home';
|
||||
import { PacketsRoute } from './packets';
|
||||
|
||||
const rootRoute = new RootRoute({
|
||||
component: Root,
|
||||
});
|
||||
|
||||
const indexRoute = new Route({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: IndexPage,
|
||||
});
|
||||
|
||||
const packetsRoute = new Route({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/packets',
|
||||
component: PacketsRoute,
|
||||
});
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
packetsRoute,
|
||||
]);
|
||||
|
||||
export const router = new Router({ routeTree });
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAppDispatch } from '../hooks';
|
||||
import { PacketList } from '../components/PacketList';
|
||||
import { addPacket } from '../store/slices/packetSlice';
|
||||
import { streamPackets, StreamEvent } from '../lib/api';
|
||||
|
||||
export function PacketsRoute() {
|
||||
const dispatch = useAppDispatch();
|
||||
const [connectionStatus, setConnectionStatus] = useState<string>('Connecting...');
|
||||
|
||||
useEffect(() => {
|
||||
// Set up Server-Sent Events connection using our API utility
|
||||
const cleanup = streamPackets(
|
||||
// Event handler for all event types
|
||||
(event: StreamEvent) => {
|
||||
if (event.type === 'info') {
|
||||
// Handle info events (connection status, etc.)
|
||||
setConnectionStatus(event.data);
|
||||
} else if (event.type === 'message') {
|
||||
// Handle message events (actual packet data)
|
||||
dispatch(addPacket(event.data));
|
||||
}
|
||||
},
|
||||
// On error
|
||||
() => {
|
||||
setConnectionStatus('Connection error. Reconnecting...');
|
||||
console.error('EventSource failed, reconnecting...');
|
||||
}
|
||||
);
|
||||
|
||||
// Clean up connection when component unmounts
|
||||
return cleanup;
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Mesh Network Packets</h2>
|
||||
|
||||
{/* Connection status indicator */}
|
||||
<div className="mb-4 p-2 bg-blue-50 border border-blue-200 rounded">
|
||||
<span className="font-medium">Status: </span>
|
||||
<span>{connectionStatus}</span>
|
||||
</div>
|
||||
|
||||
<p className="mb-4">
|
||||
This page displays real-time packets from the Meshtastic mesh network.
|
||||
</p>
|
||||
|
||||
<PacketList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link, Outlet } from '@tanstack/react-router';
|
||||
|
||||
export function Root() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<header className="bg-blue-600 text-white shadow-md">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Meshstream</h1>
|
||||
<nav className="space-x-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="hover:underline"
|
||||
activeProps={{
|
||||
className: 'font-bold underline',
|
||||
}}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/packets"
|
||||
className="hover:underline"
|
||||
activeProps={{
|
||||
className: 'font-bold underline',
|
||||
}}
|
||||
>
|
||||
Packets
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import packetReducer from './slices/packetSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
packets: packetReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface Packet {
|
||||
id: string;
|
||||
// We'll define the full packet structure later based on the protobuf definitions
|
||||
}
|
||||
|
||||
interface PacketState {
|
||||
packets: Packet[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: PacketState = {
|
||||
packets: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const packetSlice = createSlice({
|
||||
name: 'packets',
|
||||
initialState,
|
||||
reducers: {
|
||||
fetchPacketsStart(state) {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
},
|
||||
fetchPacketsSuccess(state, action: PayloadAction<Packet[]>) {
|
||||
state.packets = action.payload;
|
||||
state.loading = false;
|
||||
},
|
||||
fetchPacketsFailure(state, action: PayloadAction<string>) {
|
||||
state.error = action.payload;
|
||||
state.loading = false;
|
||||
},
|
||||
addPacket(state, action: PayloadAction<Packet>) {
|
||||
state.packets.push(action.payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchPacketsStart,
|
||||
fetchPacketsSuccess,
|
||||
fetchPacketsFailure,
|
||||
addPacket,
|
||||
} = packetSlice.actions;
|
||||
|
||||
export default packetSlice.reducer;
|
||||
@@ -0,0 +1,5 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Additional custom styles can be added here */
|
||||
@@ -0,0 +1,55 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock for window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock for ResizeObserver
|
||||
window.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock for EventSource
|
||||
class MockEventSource {
|
||||
onmessage: ((event: any) => void) | null = null;
|
||||
onerror: ((event: any) => void) | null = null;
|
||||
|
||||
constructor(public url: string) {}
|
||||
|
||||
close() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
addEventListener(event: string, callback: (event: any) => void) {
|
||||
if (event === 'message') {
|
||||
this.onmessage = callback;
|
||||
} else if (event === 'error') {
|
||||
this.onerror = callback;
|
||||
}
|
||||
}
|
||||
|
||||
removeEventListener(event: string, callback: (event: any) => void) {
|
||||
if (event === 'message' && this.onmessage === callback) {
|
||||
this.onmessage = null;
|
||||
} else if (event === 'error' && this.onerror === callback) {
|
||||
this.onerror = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override EventSource globally
|
||||
window.EventSource = MockEventSource as any;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Paths */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { TanStackRouterVite } from '@tanstack/router-vite-plugin';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
TanStackRouterVite(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
css: true,
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user