diff --git a/backend/src/config/rpcConfig.ts b/backend/src/config/rpcConfig.ts index f6d627f0..fa2ff8b3 100644 --- a/backend/src/config/rpcConfig.ts +++ b/backend/src/config/rpcConfig.ts @@ -70,3 +70,22 @@ export const ISSUER_DID: string = * Issuer name shown on certificates */ export const ISSUER_NAME: string = process.env.ISSUER_NAME ?? 'Web3 Student Lab'; + +const WEBRTC_STUN_DEFAULTS = ['stun:stun.l.google.com:19302']; +const WEBRTC_TURN_DEFAULTS: string[] = []; + +const parseIceServers = (value: string) => + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .map((url) => (url.startsWith('stun:') || url.startsWith('turn:') ? url : `stun:${url}`)); + +export const WEBRTC_ICE_SERVERS = [ + ...parseIceServers(process.env.WEBRTC_STUN_SERVERS ?? WEBRTC_STUN_DEFAULTS.join(',')).map((url) => ({ urls: url })), + ...parseIceServers(process.env.WEBRTC_TURN_SERVERS ?? WEBRTC_TURN_DEFAULTS.join(',')).map((url) => ({ + urls: url, + username: process.env.WEBRTC_TURN_USERNAME, + credential: process.env.WEBRTC_TURN_PASSWORD, + })), +]; diff --git a/backend/src/websocket/EventRouter.ts b/backend/src/websocket/EventRouter.ts index aaeccb04..0c5144b9 100644 --- a/backend/src/websocket/EventRouter.ts +++ b/backend/src/websocket/EventRouter.ts @@ -39,5 +39,53 @@ export class EventRouter { socket.emit('error', { message: 'Invalid payload format' }); } }); + + socket.on('webrtc:join', (roomName: string) => { + if (typeof roomName !== 'string') { + return socket.emit('error', { message: 'Invalid room name' }); + } + this.roomManager.joinRoom(socket, roomName); + socket.to(roomName).emit('webrtc:participant_joined', { + clientId: socket.id, + userId: socket.data.user?.id, + timestamp: new Date().toISOString(), + }); + }); + + socket.on('webrtc:offer', (payload: any) => { + this.relaySignal('webrtc:offer', socket, payload); + }); + + socket.on('webrtc:answer', (payload: any) => { + this.relaySignal('webrtc:answer', socket, payload); + }); + + socket.on('webrtc:ice-candidate', (payload: any) => { + this.relaySignal('webrtc:ice-candidate', socket, payload); + }); + + socket.on('webrtc:renegotiate', (payload: any) => { + this.relaySignal('webrtc:renegotiate', socket, payload); + }); + } + + private relaySignal(event: string, socket: Socket, payload: any) { + const targetClientId = payload?.targetClientId as string | undefined; + const room = payload?.room as string | undefined; + const message = { + ...payload, + fromClientId: socket.id, + timestamp: new Date().toISOString(), + }; + + if (targetClientId) { + return socket.to(targetClientId).emit(event, message); + } + + if (room) { + return socket.to(room).emit(event, message); + } + + socket.emit('error', { message: 'Missing targetClientId or room for WebRTC signaling' }); } } diff --git a/backend/src/websocket/MessageValidator.ts b/backend/src/websocket/MessageValidator.ts index a45c4edd..b4b4aed4 100644 --- a/backend/src/websocket/MessageValidator.ts +++ b/backend/src/websocket/MessageValidator.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; export const webSocketMessageSchema = z.object({ - type: z.enum(['message', 'event', 'command']), + type: z.string(), room: z.string().optional(), - payload: z.any(), - timestamp: z.number(), - senderId: z.string(), + targetClientId: z.string().optional(), + data: z.any().optional(), + timestamp: z.number().optional(), + senderId: z.string().optional(), }); export type WebSocketMessage = z.infer; diff --git a/frontend/next.config.ts b/frontend/next.config.ts index bae6a42d..1e9b385b 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -14,6 +14,29 @@ const nextConfig: NextConfig = { ...config.resolve.alias, }; + // Enable Module Federation host/remote configuration for micro-frontends + config.plugins.push( + new webpack.container.ModuleFederationPlugin({ + name: 'frontend_host', + filename: 'remoteEntry.js', + exposes: { + './SharedUi': './src/microfrontends/shared/index.ts', + './LabRemote': './src/microfrontends/remote/LabRemoteModule.tsx', + }, + remotes: { + lab_remote: 'lab_remote@http://localhost:3000/remoteEntry.js', + }, + shared: { + react: { singleton: true, eager: false, requiredVersion: false }, + 'react-dom': { singleton: true, eager: false, requiredVersion: false }, + zustand: { singleton: true, eager: false, requiredVersion: false }, + d3: { singleton: true, eager: false, requiredVersion: false }, + axios: { singleton: true, eager: false, requiredVersion: false }, + '@stellar/stellar-sdk': { singleton: true, eager: false, requiredVersion: false }, + }, + }) + ); + // Split chunks for better caching if (!config.optimization.splitChunks) { config.optimization.splitChunks = {}; diff --git a/frontend/package.json b/frontend/package.json index 1bf92bb1..2ec083f4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,8 @@ "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^14.6.1", "@xstate/react": "^6.1.0", + "@ledgerhq/hw-app-eth": "^7.73.0", + "@ledgerhq/hw-transport-webhid": "^7.73.0", "axios": "^1.13.6", "bignumber.js": "^10.0.2", "clsx": "^2.1.1", diff --git a/frontend/src/app/contract-performance/page.tsx b/frontend/src/app/contract-performance/page.tsx new file mode 100644 index 00000000..07008c32 --- /dev/null +++ b/frontend/src/app/contract-performance/page.tsx @@ -0,0 +1,55 @@ +import ScatterPlot, { type ScatterDatum } from '@/components/analytics/PerformanceVisualizations/ScatterPlot'; +import Heatmap, { type HeatmapDatum } from '@/components/analytics/PerformanceVisualizations/Heatmap'; +import NetworkGraph, { type LinkDatum, type NodeDatum } from '@/components/analytics/PerformanceVisualizations/NetworkGraph'; + +const scatterData: ScatterDatum[] = Array.from({ length: 80 }, (_, index) => ({ + x: Math.random() * 100, + y: Math.random() * 100, + r: Math.random() * 8 + 4, + label: `Transaction ${index + 1}`, +})); + +const heatmapData: HeatmapDatum[] = ['A', 'B', 'C', 'D', 'E', 'F', 'G'].flatMap((x) => + ['1', '2', '3', '4', '5'].map((y) => ({ + x, + y, + value: Math.random() * 100, + })) +); + +const networkNodes: NodeDatum[] = [ + { id: 'Contract', group: 1 }, + { id: 'Verifier', group: 2 }, + { id: 'Storage', group: 2 }, + { id: 'Oracle', group: 3 }, + { id: 'Scheduler', group: 3 }, +]; + +const networkLinks: LinkDatum[] = [ + { source: 'Contract', target: 'Verifier', value: 1 }, + { source: 'Contract', target: 'Storage', value: 1 }, + { source: 'Contract', target: 'Oracle', value: 1 }, + { source: 'Oracle', target: 'Scheduler', value: 1 }, +]; + +export default function ContractPerformancePage() { + return ( +
+
+
+

Contract Performance Insights

+

+ Visualize execution metrics for deployed contracts with D3-powered components designed for large datasets. +

+
+ +
+ + +
+ + +
+
+ ); +} diff --git a/frontend/src/app/hardware-wallet/page.tsx b/frontend/src/app/hardware-wallet/page.tsx new file mode 100644 index 00000000..d437639b --- /dev/null +++ b/frontend/src/app/hardware-wallet/page.tsx @@ -0,0 +1,17 @@ +import HardwareWalletConnector from '@/components/hardware-wallet/HardwareWalletConnector'; + +export default function HardwareWalletPage() { + return ( +
+
+
+

Hardware Wallet Lab

+

+ Learn hardware wallet integration with direct Ledger support via WebHID and explicit signing workflows. +

+
+ +
+
+ ); +} diff --git a/frontend/src/app/microfrontends/page.tsx b/frontend/src/app/microfrontends/page.tsx new file mode 100644 index 00000000..24f3a53b --- /dev/null +++ b/frontend/src/app/microfrontends/page.tsx @@ -0,0 +1,17 @@ +import MicroFrontendHost from '@/microfrontends/host/MicroFrontendHost'; + +export default function MicrofrontendsPage() { + return ( +
+
+
+

Micro-Frontends Lab

+

+ Module Federation enables the frontend to host independent lab modules while sharing core UI and state. +

+
+ +
+
+ ); +} diff --git a/frontend/src/app/webrtc/page.tsx b/frontend/src/app/webrtc/page.tsx new file mode 100644 index 00000000..31f1c2a9 --- /dev/null +++ b/frontend/src/app/webrtc/page.tsx @@ -0,0 +1,17 @@ +import CallRoom from '@/components/webrtc/CallRoom'; + +export default function WebRTCPage() { + return ( +
+
+
+

WebRTC Lab Room

+

+ Connect directly from the lab environment using peer-to-peer audio/video and screen sharing. +

+
+ +
+
+ ); +} diff --git a/frontend/src/components/analytics/PerformanceVisualizations/Heatmap.tsx b/frontend/src/components/analytics/PerformanceVisualizations/Heatmap.tsx new file mode 100644 index 00000000..c8b9383d --- /dev/null +++ b/frontend/src/components/analytics/PerformanceVisualizations/Heatmap.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { axisBottom, axisLeft, interpolateViridis, scaleBand, scaleSequential, select } from 'd3'; + +export interface HeatmapDatum { + x: string; + y: string; + value: number; +} + +interface Props { + data: HeatmapDatum[]; + width?: number; + height?: number; +} + +export default function Heatmap({ data, width = 680, height = 420 }: Props) { + const svgRef = useRef(null); + const [tooltip, setTooltip] = useState<{ x: number; y: number; value: number } | null>(null); + + const margin = { top: 28, right: 24, bottom: 40, left: 64 }; + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + const xKeys = useMemo(() => Array.from(new Set(data.map((item) => item.x))), [data]); + const yKeys = useMemo(() => Array.from(new Set(data.map((item) => item.y))).reverse(), [data]); + const valueExtent = useMemo(() => [Math.min(...data.map((item) => item.value)), Math.max(...data.map((item) => item.value))] as [number, number], [data]); + + useEffect(() => { + const svgElement = svgRef.current; + if (!svgElement || data.length === 0) return; + + const svg = select(svgElement); + svg.selectAll('*').remove(); + + const xScale = scaleBand().domain(xKeys).range([margin.left, margin.left + innerWidth]).padding(0.08); + const yScale = scaleBand().domain(yKeys).range([margin.top, margin.top + innerHeight]).padding(0.08); + const colorScale = scaleSequential(interpolateViridis).domain([valueExtent[0], valueExtent[1]]); + + svg.append('g') + .attr('transform', `translate(0, ${margin.top + innerHeight})`) + .call(axisBottom(xScale).tickSizeOuter(0)) + .attr('color', '#94a3b8'); + + svg.append('g') + .attr('transform', `translate(${margin.left}, 0)`) + .call(axisLeft(yScale).tickSizeOuter(0)) + .attr('color', '#94a3b8'); + + svg + .append('g') + .selectAll('rect') + .data(data) + .join('rect') + .attr('x', (d) => xScale(d.x) ?? 0) + .attr('y', (d) => yScale(d.y) ?? 0) + .attr('width', xScale.bandwidth()) + .attr('height', yScale.bandwidth()) + .attr('fill', (d) => colorScale(d.value)) + .attr('stroke', '#0f172a') + .on('mouseenter', (event, d) => { + setTooltip({ x: event.clientX, y: event.clientY, value: d.value }); + }) + .on('mouseleave', () => setTooltip(null)); + }, [data, innerHeight, innerWidth, margin.left, margin.top, xKeys, yKeys, valueExtent]); + + return ( +
+
+

Contract performance heatmap

+

Heatmap visualization for performance hotspots and block-level intensity.

+
+ + {tooltip ? ( +
+ Value: {tooltip.value.toFixed(2)} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/analytics/PerformanceVisualizations/NetworkGraph.tsx b/frontend/src/components/analytics/PerformanceVisualizations/NetworkGraph.tsx new file mode 100644 index 00000000..4421bd87 --- /dev/null +++ b/frontend/src/components/analytics/PerformanceVisualizations/NetworkGraph.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { forceCenter, forceLink, forceManyBody, forceSimulation, select } from 'd3'; + +export interface NodeDatum { + id: string; + group: number; +} + +export interface LinkDatum { + source: string; + target: string; + value: number; +} + +interface Props { + nodes: NodeDatum[]; + links: LinkDatum[]; + width?: number; + height?: number; +} + +export default function NetworkGraph({ nodes, links, width = 680, height = 420 }: Props) { + const svgRef = useRef(null); + const [layout, setLayout] = useState([]); + + const linkData = useMemo( + () => links.map((link) => ({ ...link })), + [links] + ); + + useEffect(() => { + const svgElement = svgRef.current; + if (!svgElement) return; + + const simulation = forceSimulation(nodes) + .force('link', forceLink(linkData).id((d) => d.id).distance(110).strength(0.2)) + .force('charge', forceManyBody().strength(-180)) + .force('center', forceCenter(width / 2, height / 2)); + + simulation.on('tick', () => { + setLayout(nodes.map((node) => ({ ...node, x: (node as any).x ?? width / 2, y: (node as any).y ?? height / 2 }))); + }); + + return () => { + simulation.stop(); + }; + }, [height, linkData, nodes, width]); + + useEffect(() => { + const svgElement = svgRef.current; + if (!svgElement) return; + + const svg = select(svgElement); + svg.selectAll('*').remove(); + + svg + .append('g') + .attr('class', 'links') + .selectAll('line') + .data(linkData) + .join('line') + .attr('stroke', '#334155') + .attr('stroke-width', (d) => Math.max(1, d.value * 0.8)); + + svg + .append('g') + .attr('class', 'nodes') + .selectAll('circle') + .data(nodes) + .join('circle') + .attr('r', 12) + .attr('fill', (d) => (d.group % 2 === 0 ? '#60a5fa' : '#facc15')) + .attr('stroke', '#0f172a') + .attr('stroke-width', 2); + + const tick = () => { + svg.selectAll('.links line') + .data(linkData) + .attr('x1', (d) => ((d.source as any).x ?? width / 2)) + .attr('y1', (d) => ((d.source as any).y ?? height / 2)) + .attr('x2', (d) => ((d.target as any).x ?? width / 2)) + .attr('y2', (d) => ((d.target as any).y ?? height / 2)); + + svg.selectAll('.nodes circle') + .data(nodes) + .attr('cx', (d) => ((d as any).x ?? width / 2)) + .attr('cy', (d) => ((d as any).y ?? height / 2)); + }; + + const interval = window.setInterval(tick, 50); + tick(); + return () => window.clearInterval(interval); + }, [height, linkData, nodes, width]); + + return ( +
+
+

Network graph

+

Visualize contract execution and call topology with an interactive force layout.

+
+ +
+ ); +} diff --git a/frontend/src/components/analytics/PerformanceVisualizations/ScatterPlot.tsx b/frontend/src/components/analytics/PerformanceVisualizations/ScatterPlot.tsx new file mode 100644 index 00000000..cbe42b8c --- /dev/null +++ b/frontend/src/components/analytics/PerformanceVisualizations/ScatterPlot.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { axisBottom, axisLeft, extent, scaleLinear, select, zoom, zoomTransform } from 'd3'; + +export interface ScatterDatum { + x: number; + y: number; + r?: number; + label?: string; +} + +interface Props { + data: ScatterDatum[]; + width?: number; + height?: number; +} + +export default function ScatterPlot({ data, width = 680, height = 420 }: Props) { + const svgRef = useRef(null); + const [tooltip, setTooltip] = useState<{ x: number; y: number; label: string } | null>(null); + + const margin = { top: 28, right: 24, bottom: 40, left: 48 }; + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + const [rendered, setRendered] = useState(false); + + const xDomain = useMemo(() => extent(data, (item) => item.x) as [number, number], [data]); + const yDomain = useMemo(() => extent(data, (item) => item.y) as [number, number], [data]); + + useEffect(() => { + const svgElement = svgRef.current; + if (!svgElement || data.length === 0) return; + + const id = window.requestIdleCallback + ? window.requestIdleCallback(() => setRendered(true)) + : window.setTimeout(() => setRendered(true), 100); + + return () => { + if ('cancelIdleCallback' in window) { + window.cancelIdleCallback(id as number); + } + window.clearTimeout(id as number); + }; + }, [data]); + + useEffect(() => { + if (!rendered) return; + const svgElement = svgRef.current; + if (!svgElement) return; + + const svg = select(svgElement); + svg.selectAll('*').remove(); + + const xScale = scaleLinear().domain([xDomain[0] ?? 0, xDomain[1] ?? 1]).nice().range([margin.left, margin.left + innerWidth]); + const yScale = scaleLinear().domain([yDomain[0] ?? 0, yDomain[1] ?? 1]).nice().range([margin.top + innerHeight, margin.top]); + + const g = svg.append('g'); + + g.append('g') + .attr('transform', `translate(0, ${margin.top + innerHeight})`) + .call(axisBottom(xScale).ticks(6).tickSizeOuter(0)) + .attr('color', '#94a3b8'); + + g.append('g') + .attr('transform', `translate(${margin.left}, 0)`) + .call(axisLeft(yScale).ticks(6).tickSizeOuter(0)) + .attr('color', '#94a3b8'); + + const pointLayer = g.append('g').attr('class', 'scatter-points'); + + pointLayer + .selectAll('circle') + .data(data) + .join('circle') + .attr('cx', (item) => xScale(item.x)) + .attr('cy', (item) => yScale(item.y)) + .attr('r', (item) => Math.max(3, item.r ?? 6)) + .attr('fill', '#38bdf8') + .attr('fill-opacity', 0.85) + .attr('stroke', '#ffffff') + .attr('stroke-width', 0.75) + .on('mouseenter', (event, item) => { + setTooltip({ x: event.clientX, y: event.clientY, label: `${item.label ?? 'Point'} (${item.x}, ${item.y})` }); + }) + .on('mouseleave', () => setTooltip(null)); + + const zoomBehavior = zoom() + .scaleExtent([1, 8]) + .on('zoom', (event) => { + pointLayer.attr('transform', event.transform.toString()); + g.selectAll('g').attr('transform', event.transform.toString()); + }); + + svg.call(zoomBehavior); + }, [data, innerHeight, innerWidth, margin.left, margin.top, rendered, xDomain, yDomain]); + + return ( +
+
+
+

Contract performance scatter plot

+

Interactive scatter plot with zooming, panning, and tooltips.

+
+
+ + {tooltip ? ( +
+ {tooltip.label} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/hardware-wallet/HardwareWalletConnector.tsx b/frontend/src/components/hardware-wallet/HardwareWalletConnector.tsx new file mode 100644 index 00000000..a0ad2e5e --- /dev/null +++ b/frontend/src/components/hardware-wallet/HardwareWalletConnector.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState } from 'react'; +import { useHardwareWallet } from '@/hooks/useHardwareWallet'; + +export default function HardwareWalletConnector() { + const { isSupported, isConnected, isBusy, address, error, connect, disconnect, signPersonalMessage } = useHardwareWallet(); + const [message, setMessage] = useState('This is a sample contract signing request.'); + const [signature, setSignature] = useState(null); + + const handleSign = async () => { + const result = await signPersonalMessage(message); + setSignature(result); + }; + + return ( +
+
+

Hardware Wallet Integration

+

+ Connect a Ledger-compatible device directly via WebHID for secure transaction signing. +

+
+ +
+
+

Browser support

+

{isSupported ? 'Supported' : 'Unsupported'}

+
+
+

Wallet status

+

{isConnected ? 'Connected' : 'Disconnected'}

+ {address ?

{address}

: null} +
+
+ +
+ + +
+ +
+