Initial commit: UlHub workspace
This commit is contained in:
12
web/Dockerfile
Normal file
12
web/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
5
web/next-env.d.ts
vendored
Normal file
5
web/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
16
web/next.config.mjs
Normal file
16
web/next.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const backendHost = process.env.BACKEND_HOST || 'http://backend:3001';
|
||||
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${backendHost}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
24
web/package.json
Normal file
24
web/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "ulhub-web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.5",
|
||||
"react": "18.3.0",
|
||||
"react-dom": "18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
163
web/pages/index.tsx
Normal file
163
web/pages/index.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import Head from 'next/head';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface ApiStatus {
|
||||
message: string;
|
||||
docs: string;
|
||||
}
|
||||
|
||||
interface MqttMessage {
|
||||
topic: string;
|
||||
payload: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Paho?: {
|
||||
Client: new (...args: any[]) => any;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [status, setStatus] = useState<ApiStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<MqttMessage[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const clientId = useMemo(() => `ulhub-web-${Math.random().toString(16).slice(2)}`, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/status')
|
||||
.then((res) => res.json())
|
||||
.then(setStatus)
|
||||
.catch((err) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.min.js';
|
||||
script.async = true;
|
||||
console.log('Appending Paho script to page:', script.src);
|
||||
script.onerror = () => {
|
||||
console.error('Failed to load Paho MQTT script from CDN');
|
||||
setError('Failed to load MQTT client script');
|
||||
};
|
||||
script.onload = () => {
|
||||
console.log('Paho script loaded', !!window.Paho);
|
||||
console.log('Paho global:', window.Paho);
|
||||
// connect to same-origin via nginx proxy at /mqtt so browsers use wss on HTTPS
|
||||
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const uri = `${scheme}://${window.location.host}/mqtt`;
|
||||
console.log('Connecting Paho client to', uri);
|
||||
|
||||
let client: any;
|
||||
try {
|
||||
if (window.Paho && typeof (window.Paho as any).Client === 'function') {
|
||||
client = new (window.Paho as any).Client(uri, clientId);
|
||||
} else if (window.Paho && (window.Paho as any).MQTT && typeof (window.Paho as any).MQTT.Client === 'function') {
|
||||
// Paho.MQTT.Client(host, port, path, clientId)
|
||||
const url = new URL(uri);
|
||||
const host = url.hostname;
|
||||
const port = url.port ? Number(url.port) : (url.protocol === 'wss:' ? 443 : 80);
|
||||
const path = url.pathname + (url.search || '');
|
||||
client = new (window.Paho as any).MQTT.Client(host, port, path, clientId);
|
||||
} else {
|
||||
console.error('Paho client constructor not found', window.Paho);
|
||||
setError('MQTT client not available in browser');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to instantiate Paho client', e);
|
||||
setError(String(e));
|
||||
return;
|
||||
}
|
||||
|
||||
client.onConnectionLost = (responseObject: { errorCode: number; errorMessage: string }) => {
|
||||
console.warn('Paho connection lost', responseObject);
|
||||
setConnected(false);
|
||||
setError(responseObject.errorMessage || 'MQTT connection lost');
|
||||
};
|
||||
|
||||
client.onMessageArrived = (message: { topic: string; payloadString: string }) => {
|
||||
setMessages((prev) => [
|
||||
{
|
||||
topic: message.topic,
|
||||
payload: message.payloadString,
|
||||
receivedAt: new Date().toLocaleTimeString(),
|
||||
},
|
||||
...prev,
|
||||
].slice(0, 10));
|
||||
};
|
||||
|
||||
const connectOptions: any = {
|
||||
onSuccess: () => {
|
||||
console.log('Paho connected');
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
client.subscribe('devices/#');
|
||||
},
|
||||
onFailure: (err: { errorMessage: string }) => {
|
||||
console.error('Paho connect failure', err);
|
||||
setConnected(false);
|
||||
setError(err.errorMessage || 'Unable to connect to MQTT broker');
|
||||
},
|
||||
};
|
||||
// force secure websocket when page is served over HTTPS
|
||||
connectOptions.useSSL = window.location.protocol === 'https:';
|
||||
console.log('Paho connect options:', connectOptions);
|
||||
client.connect(connectOptions);
|
||||
|
||||
return () => {
|
||||
client.disconnect();
|
||||
};
|
||||
};
|
||||
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}, [clientId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>UlHub</title>
|
||||
<meta name="description" content="UlHub monolithic app" />
|
||||
</Head>
|
||||
<main style={{ padding: '3rem', fontFamily: 'system-ui, sans-serif', maxWidth: '900px' }}>
|
||||
<h1>UlHub</h1>
|
||||
<p>React + Next.js frontend with NestJS backend and live MQTT WebSocket data.</p>
|
||||
|
||||
<section style={{ marginTop: '1.5rem' }}>
|
||||
<h2>API Status</h2>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
{status ? (
|
||||
<div>
|
||||
<p>{status.message}</p>
|
||||
<p>{status.docs}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Loading status...</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: '1.5rem' }}>
|
||||
<h2>MQTT WebSocket</h2>
|
||||
<p>Status: {connected ? 'Connected' : 'Connecting...'}</p>
|
||||
<ul>
|
||||
{messages.map((msg, index) => (
|
||||
<li key={`${msg.topic}-${index}`} style={{ marginBottom: '0.75rem' }}>
|
||||
<strong>{msg.topic}</strong> <span style={{ color: '#666' }}>{msg.receivedAt}</span>
|
||||
<br />
|
||||
{msg.payload}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
web/tsconfig.json
Normal file
21
web/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"lib": ["dom", "dom.iterable", "es2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user