Initial skeleton for web application

This commit is contained in:
Daniel Pupius
2025-04-22 13:26:35 -07:00
parent c6de28aacb
commit 6844d575c4
39 changed files with 5523 additions and 33 deletions
+1
View File
@@ -1,5 +1,6 @@
# Binaries and build artifacts
/dist/
/web/dist/
/bin/
# Go specific
+23 -2
View File
@@ -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
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
# Development environment variables
VITE_API_BASE_URL="http://localhost:8080"
VITE_APP_ENV="development"
+3
View File
@@ -0,0 +1,3 @@
# Production environment variables
VITE_API_BASE_URL=""
VITE_APP_ENV="production"
+18
View File
@@ -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 }
]
}
}
+47
View File
@@ -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.
+31
View File
@@ -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',
},
},
},
];
+13
View File
@@ -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
View File
@@ -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"
}
}
+4367
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};
+24
View File
@@ -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>
);
};
+25
View File
@@ -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>
);
};
+16
View File
@@ -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>
);
};
+24
View File
@@ -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();
});
});
+31
View File
@@ -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>
);
};
+4
View File
@@ -0,0 +1,4 @@
export * from './PacketList';
export * from './MessageDisplay';
export * from './PacketDetails';
export * from './Filter';
+2
View File
@@ -0,0 +1,2 @@
export * from './useAppDispatch';
export * from './useAppSelector';
+4
View File
@@ -0,0 +1,4 @@
import { useDispatch } from 'react-redux';
import type { AppDispatch } from '../store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
+4
View File
@@ -0,0 +1,4 @@
import { useSelector, TypedUseSelectorHook } from 'react-redux';
import type { RootState } from '../store';
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
+121
View File
@@ -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);
}
}
+27
View File
@@ -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`,
};
+15
View File
@@ -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>,
);
+157
View File
@@ -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 */
+38
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+33
View File
@@ -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;
}
}
+52
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+11
View File
@@ -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;
+49
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Additional custom styles can be added here */
+55
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};
+31
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+36
View File
@@ -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,
},
});