Intial Version
This commit is contained in:
5
electron-app/.env.example
Normal file
5
electron-app/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# WebSocket connection URL
|
||||
WS_URL=ws://localhost:3000/ws
|
||||
|
||||
# Development mode
|
||||
NODE_ENV=development
|
||||
62
electron-app/.gitignore
vendored
Normal file
62
electron-app/.gitignore
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
dist-electron/
|
||||
out/
|
||||
build/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.DS_Store
|
||||
|
||||
# OS files
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Electron specific
|
||||
*.asar
|
||||
*.dmg
|
||||
*.exe
|
||||
*.AppImage
|
||||
*.deb
|
||||
*.rpm
|
||||
*.snap
|
||||
|
||||
# Release builds
|
||||
release/
|
||||
releases/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
61
electron-app/README.md
Normal file
61
electron-app/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# rmtPocketWatcher - Electron Desktop App
|
||||
|
||||
Cross-platform desktop application for tracking AUEC RMT prices in real-time.
|
||||
|
||||
## WebSocket Implementation
|
||||
|
||||
The app uses a robust WebSocket client with the following features:
|
||||
|
||||
### Main Process (src/main/)
|
||||
- **websocket-client.ts**: Core WebSocket client with auto-reconnect logic
|
||||
- Exponential backoff reconnection (max 10 attempts)
|
||||
- Event-based architecture for easy integration
|
||||
- Connection state management
|
||||
|
||||
- **ipc-handlers.ts**: IPC bridge between main and renderer processes
|
||||
- Forwards WebSocket events to renderer
|
||||
- Handles renderer commands (connect/disconnect/send)
|
||||
- Auto-connects on app startup
|
||||
|
||||
### Renderer Process (src/renderer/)
|
||||
- **hooks/useWebSocket.ts**: React hook for WebSocket integration
|
||||
- Manages connection status
|
||||
- Provides latest price updates
|
||||
- Exposes connect/disconnect/send methods
|
||||
|
||||
### Shared Types (src/shared/)
|
||||
- **types.ts**: TypeScript interfaces shared between processes
|
||||
- PriceIndex, VendorPrice, WebSocketMessage
|
||||
- Ensures type safety across IPC boundary
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start Vite dev server and Electron
|
||||
npm run electron:dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Build for production
|
||||
npm run electron:build
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
- `WS_URL`: WebSocket server URL (default: ws://localhost:3000/ws)
|
||||
|
||||
## Security
|
||||
|
||||
- Context isolation enabled
|
||||
- Sandbox mode enabled
|
||||
- No remote code evaluation
|
||||
- Secure IPC communication via preload script
|
||||
51
electron-app/index.html
Normal file
51
electron-app/index.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">
|
||||
<title>rmtPocketWatcher</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
#root::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
#root::-webkit-scrollbar-track {
|
||||
background: #1a1f3a;
|
||||
}
|
||||
|
||||
#root::-webkit-scrollbar-thumb {
|
||||
background: #50e3c2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
#root::-webkit-scrollbar-thumb:hover {
|
||||
background: #3cc9a8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/renderer/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
61
electron-app/package.json
Normal file
61
electron-app/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "rmtpocketwatcher",
|
||||
"version": "1.0.0",
|
||||
"description": "Real-time AUEC price tracking desktop application",
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:main": "tsc --project tsconfig.main.json && tsc --project tsconfig.preload.json",
|
||||
"build:renderer": "vite build",
|
||||
"build": "npm run build:main && npm run build:renderer",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron dist/main/index.js\"",
|
||||
"electron:build": "npm run build && electron-builder",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron": "^30.0.0",
|
||||
"electron-updater": "^6.1.0",
|
||||
"recharts": "^3.5.1",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.3.27",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"concurrently": "^8.2.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron-builder": "^24.9.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.lambdabanking.rmtpocketwatcher",
|
||||
"productName": "rmtPocketWatcher",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
53
electron-app/src/main/index.ts
Normal file
53
electron-app/src/main/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { setupIpcHandlers, cleanupIpcHandlers } from './ipc-handlers';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
},
|
||||
title: 'rmtPocketWatcher',
|
||||
});
|
||||
|
||||
// Setup IPC handlers for WebSocket communication
|
||||
setupIpcHandlers(mainWindow);
|
||||
|
||||
// Load the app
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
mainWindow.loadURL('http://localhost:5173');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'));
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
cleanupIpcHandlers();
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
cleanupIpcHandlers();
|
||||
});
|
||||
73
electron-app/src/main/ipc-handlers.ts
Normal file
73
electron-app/src/main/ipc-handlers.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { WebSocketClient } from './websocket-client';
|
||||
import { PriceIndex } from '../shared/types';
|
||||
|
||||
let wsClient: WebSocketClient | null = null;
|
||||
|
||||
export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
const wsUrl = process.env.WS_URL || 'ws://localhost:3000/ws';
|
||||
|
||||
// Initialize WebSocket client
|
||||
wsClient = new WebSocketClient(wsUrl);
|
||||
|
||||
// Forward WebSocket events to renderer
|
||||
wsClient.on('connected', () => {
|
||||
mainWindow.webContents.send('ws:connected');
|
||||
});
|
||||
|
||||
wsClient.on('disconnected', () => {
|
||||
mainWindow.webContents.send('ws:disconnected');
|
||||
});
|
||||
|
||||
wsClient.on('priceUpdate', (data: PriceIndex) => {
|
||||
mainWindow.webContents.send('ws:priceUpdate', data);
|
||||
});
|
||||
|
||||
wsClient.on('historyData', (data: any) => {
|
||||
mainWindow.webContents.send('ws:historyData', data);
|
||||
});
|
||||
|
||||
wsClient.on('error', (error: Error) => {
|
||||
mainWindow.webContents.send('ws:error', error.message);
|
||||
});
|
||||
|
||||
wsClient.on('maxReconnectAttemptsReached', () => {
|
||||
mainWindow.webContents.send('ws:maxReconnectAttemptsReached');
|
||||
});
|
||||
|
||||
// IPC handlers for renderer requests
|
||||
ipcMain.handle('ws:connect', async () => {
|
||||
wsClient?.connect();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('ws:disconnect', async () => {
|
||||
wsClient?.disconnect();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('ws:getStatus', async () => {
|
||||
return {
|
||||
connected: wsClient?.isConnected() || false,
|
||||
state: wsClient?.getConnectionState() || 'DISCONNECTED',
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('ws:send', async (_event, message: any) => {
|
||||
wsClient?.sendMessage(message);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Auto-connect on startup
|
||||
wsClient.connect();
|
||||
}
|
||||
|
||||
export function cleanupIpcHandlers(): void {
|
||||
wsClient?.disconnect();
|
||||
wsClient = null;
|
||||
|
||||
ipcMain.removeHandler('ws:connect');
|
||||
ipcMain.removeHandler('ws:disconnect');
|
||||
ipcMain.removeHandler('ws:getStatus');
|
||||
ipcMain.removeHandler('ws:send');
|
||||
}
|
||||
145
electron-app/src/main/websocket-client.ts
Normal file
145
electron-app/src/main/websocket-client.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import WebSocket from 'ws';
|
||||
import { EventEmitter } from 'events';
|
||||
import { PriceIndex, WebSocketMessage, HistoricalData } from '../shared/types';
|
||||
|
||||
export class WebSocketClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private reconnectInterval: number = 5000;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private isIntentionallyClosed: boolean = false;
|
||||
private maxReconnectAttempts: number = 10;
|
||||
private reconnectAttempts: number = 0;
|
||||
|
||||
constructor(url: string) {
|
||||
super();
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
console.log('WebSocket already connected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isIntentionallyClosed = false;
|
||||
console.log(`Connecting to WebSocket: ${this.url}`);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('WebSocket connected');
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit('connected');
|
||||
this.sendMessage({ type: 'subscribe', data: { channel: 'price_updates' } });
|
||||
});
|
||||
|
||||
this.ws.on('message', (data: WebSocket.Data) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(data.toString());
|
||||
this.handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', (code: number, reason: Buffer) => {
|
||||
console.log(`WebSocket closed: ${code} - ${reason.toString()}`);
|
||||
this.emit('disconnected');
|
||||
|
||||
if (!this.isIntentionallyClosed) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (error: Error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.emit('error', error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket connection:', error);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: WebSocketMessage): void {
|
||||
switch (message.type) {
|
||||
case 'price_update':
|
||||
this.emit('priceUpdate', message.data as PriceIndex);
|
||||
break;
|
||||
case 'history_data':
|
||||
this.emit('historyData', message.data as HistoricalData);
|
||||
break;
|
||||
case 'connection_status':
|
||||
this.emit('status', message.data);
|
||||
break;
|
||||
case 'error':
|
||||
this.emit('error', new Error(message.data));
|
||||
break;
|
||||
default:
|
||||
console.warn('Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer || this.isIntentionallyClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnection attempts reached');
|
||||
this.emit('maxReconnectAttemptsReached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = Math.min(this.reconnectInterval * this.reconnectAttempts, 30000);
|
||||
|
||||
console.log(`Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
sendMessage(message: any): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('WebSocket not connected, cannot send message');
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.isIntentionallyClosed = true;
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
getConnectionState(): string {
|
||||
if (!this.ws) return 'DISCONNECTED';
|
||||
|
||||
switch (this.ws.readyState) {
|
||||
case WebSocket.CONNECTING: return 'CONNECTING';
|
||||
case WebSocket.OPEN: return 'CONNECTED';
|
||||
case WebSocket.CLOSING: return 'CLOSING';
|
||||
case WebSocket.CLOSED: return 'DISCONNECTED';
|
||||
default: return 'UNKNOWN';
|
||||
}
|
||||
}
|
||||
}
|
||||
34
electron-app/src/preload.ts
Normal file
34
electron-app/src/preload.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// the ipcRenderer without exposing the entire object
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
ipcRenderer: {
|
||||
invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args),
|
||||
on: (channel: string, func: (...args: any[]) => void) => {
|
||||
ipcRenderer.on(channel, (_event: any, ...args: any[]) => func(...args));
|
||||
},
|
||||
once: (channel: string, func: (...args: any[]) => void) => {
|
||||
ipcRenderer.once(channel, (_event: any, ...args: any[]) => func(...args));
|
||||
},
|
||||
removeAllListeners: (channel: string) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Type definitions for TypeScript
|
||||
export interface IElectronAPI {
|
||||
ipcRenderer: {
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
on: (channel: string, func: (...args: any[]) => void) => void;
|
||||
once: (channel: string, func: (...args: any[]) => void) => void;
|
||||
removeAllListeners: (channel: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: IElectronAPI;
|
||||
}
|
||||
}
|
||||
303
electron-app/src/renderer/App.tsx
Normal file
303
electron-app/src/renderer/App.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: '6 Hours', value: '6h' },
|
||||
{ label: '24 Hours', value: '24h' },
|
||||
{ label: '3 Days', value: '3d' },
|
||||
{ label: '7 Days', value: '7d' },
|
||||
{ label: '1 Month', value: '1mo' },
|
||||
{ label: 'YTD', value: 'ytd' },
|
||||
];
|
||||
|
||||
const COLORS = [
|
||||
'#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#a28fd0', '#f5a623',
|
||||
'#50e3c2', '#ff6b9d', '#4a90e2', '#7ed321', '#d0021b', '#f8e71c'
|
||||
];
|
||||
|
||||
export function App() {
|
||||
const { status, latestPrice, historyData, requestHistory } = useWebSocket();
|
||||
const [selectedRange, setSelectedRange] = useState('7d');
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.connected) {
|
||||
requestHistory(selectedRange);
|
||||
}
|
||||
}, [status.connected, selectedRange, requestHistory]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!historyData?.prices) return [];
|
||||
|
||||
// Group by timestamp and seller
|
||||
const grouped = new Map<string, any>();
|
||||
|
||||
historyData.prices.forEach(price => {
|
||||
const time = new Date(price.timestamp).getTime();
|
||||
const key = time.toString();
|
||||
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, { timestamp: time, time: new Date(price.timestamp).toLocaleString() });
|
||||
}
|
||||
|
||||
const entry = grouped.get(key);
|
||||
const sellerKey = `${price.sellerName}`;
|
||||
entry[sellerKey] = price.pricePerMillion;
|
||||
});
|
||||
|
||||
return Array.from(grouped.values()).sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [historyData]);
|
||||
|
||||
const sellers = useMemo(() => {
|
||||
if (!historyData?.prices) return [];
|
||||
const uniqueSellers = new Set(historyData.prices.map(p => p.sellerName));
|
||||
return Array.from(uniqueSellers);
|
||||
}, [historyData]);
|
||||
|
||||
const { minY, maxY } = useMemo(() => {
|
||||
if (!historyData?.prices || historyData.prices.length === 0) {
|
||||
return { minY: 0, maxY: 1 };
|
||||
}
|
||||
|
||||
// Get all prices and sort them
|
||||
const allPrices = historyData.prices.map(p => p.pricePerMillion).sort((a, b) => a - b);
|
||||
|
||||
// Find the 90th percentile to focus on competitive prices
|
||||
const percentile90Index = Math.floor(allPrices.length * 0.90);
|
||||
const percentile90 = allPrices[percentile90Index];
|
||||
|
||||
// Get the minimum price
|
||||
const minPrice = allPrices[0];
|
||||
|
||||
// Base max Y on 90th percentile with padding
|
||||
const baseMaxY = Math.max(percentile90 * 1.1, minPrice * 2);
|
||||
|
||||
// Apply zoom level (higher zoom = smaller range)
|
||||
const zoomedMaxY = baseMaxY / zoomLevel;
|
||||
|
||||
console.log('Y-axis domain:', { minY: 0, maxY: zoomedMaxY, zoom: zoomLevel, base: baseMaxY });
|
||||
|
||||
return { minY: 0, maxY: zoomedMaxY };
|
||||
}, [historyData, zoomLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
const chartContainer = chartContainerRef.current;
|
||||
if (!chartContainer) {
|
||||
console.log('Chart container ref not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Setting up wheel event listener on chart container');
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
console.log('Wheel event:', e.deltaY);
|
||||
|
||||
// Zoom in when scrolling up (negative deltaY), zoom out when scrolling down
|
||||
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
|
||||
setZoomLevel(prev => {
|
||||
const newZoom = prev * zoomDelta;
|
||||
const clampedZoom = Math.max(0.1, Math.min(10, newZoom));
|
||||
console.log('Zoom level:', prev, '->', clampedZoom);
|
||||
return clampedZoom;
|
||||
});
|
||||
};
|
||||
|
||||
chartContainer.addEventListener('wheel', handleWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
console.log('Removing wheel event listener');
|
||||
chartContainer.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRangeChange = (range: string) => {
|
||||
setSelectedRange(range);
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
setZoomLevel(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
fontFamily: 'system-ui',
|
||||
backgroundColor: '#0a0e27',
|
||||
color: '#fff',
|
||||
minHeight: '100%'
|
||||
}}>
|
||||
<h1 style={{ color: '#50e3c2', marginBottom: '10px' }}>rmtPocketWatcher</h1>
|
||||
<p style={{ color: '#888', fontSize: '14px', marginBottom: '20px' }}>Lambda Banking Conglomerate</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '20px', marginBottom: '20px' }}>
|
||||
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#888', marginBottom: '5px' }}>CONNECTION</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', color: status.connected ? '#50e3c2' : '#ff6b9d' }}>
|
||||
{status.state}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latestPrice && (
|
||||
<>
|
||||
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#888', marginBottom: '5px' }}>LOWEST PRICE</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#50e3c2' }}>
|
||||
${latestPrice.lowestPrice.toFixed(4)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>per 1M AUEC</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#888', marginBottom: '5px' }}>SELLER</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>{latestPrice.sellerName}</div>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>{latestPrice.platform}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h2 style={{ margin: 0 }}>Price History</h2>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
{TIME_RANGES.map(range => (
|
||||
<button
|
||||
key={range.value}
|
||||
onClick={() => handleRangeChange(range.value)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: selectedRange === range.value ? '#50e3c2' : '#2a2f4a',
|
||||
color: selectedRange === range.value ? '#0a0e27' : '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: selectedRange === range.value ? 'bold' : 'normal',
|
||||
}}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{historyData && historyData.prices && historyData.prices.length > 0 ? (
|
||||
chartData.length > 0 ? (
|
||||
<div>
|
||||
<div style={{ marginBottom: '10px', color: '#888', fontSize: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Scroll on chart to zoom. Extreme outliers may be clipped for better visibility.</span>
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
Reset Zoom (×{zoomLevel.toFixed(1)})
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={chartContainerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '500px',
|
||||
minHeight: '400px',
|
||||
cursor: 'ns-resize',
|
||||
border: '1px solid #2a2f4a'
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} key={`chart-${zoomLevel}`}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2a2f4a" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888', fontSize: 10 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888', fontSize: 12 }}
|
||||
label={{ value: 'USD per 1M AUEC', angle: -90, position: 'insideLeft', fill: '#888' }}
|
||||
domain={[0, yAxisDomain[1]]}
|
||||
allowDataOverflow={true}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1a1f3a', border: '1px solid #2a2f4a', borderRadius: '4px' }}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ color: '#fff', maxHeight: '100px', overflowY: 'auto' }}
|
||||
iconType="line"
|
||||
/>
|
||||
{sellers.map((seller, index) => (
|
||||
<Line
|
||||
key={seller}
|
||||
type="monotone"
|
||||
dataKey={seller}
|
||||
stroke={COLORS[index % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#ff6b9d' }}>
|
||||
Data received but chart data is empty. Check console for details.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
|
||||
{status.connected ? 'Loading historical data...' : 'Connect to view historical data'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{latestPrice && (
|
||||
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px' }}>
|
||||
<h2>Current Listings ({latestPrice.allPrices.length})</h2>
|
||||
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: '400px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, backgroundColor: '#1a1f3a', zIndex: 1 }}>
|
||||
<tr style={{ borderBottom: '2px solid #2a2f4a' }}>
|
||||
<th style={{ textAlign: 'left', padding: '12px', color: '#888', fontWeight: 'normal' }}>Platform</th>
|
||||
<th style={{ textAlign: 'left', padding: '12px', color: '#888', fontWeight: 'normal' }}>Seller</th>
|
||||
<th style={{ textAlign: 'right', padding: '12px', color: '#888', fontWeight: 'normal' }}>Price/1M AUEC</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{latestPrice.allPrices.map((price, index) => (
|
||||
<tr key={price.id} style={{ borderBottom: '1px solid #2a2f4a' }}>
|
||||
<td style={{ padding: '12px' }}>{price.platform}</td>
|
||||
<td style={{ padding: '12px' }}>{price.sellerName}</td>
|
||||
<td style={{ textAlign: 'right', padding: '12px', color: '#50e3c2', fontWeight: 'bold' }}>
|
||||
${price.pricePerMillion.toFixed(4)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
electron-app/src/renderer/hooks/useWebSocket.ts
Normal file
96
electron-app/src/renderer/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { PriceIndex, HistoricalData } from '../../shared/types';
|
||||
|
||||
interface WebSocketStatus {
|
||||
connected: boolean;
|
||||
state: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
const [status, setStatus] = useState<WebSocketStatus>({
|
||||
connected: false,
|
||||
state: 'DISCONNECTED',
|
||||
});
|
||||
const [latestPrice, setLatestPrice] = useState<PriceIndex | null>(null);
|
||||
const [historyData, setHistoryData] = useState<HistoricalData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for WebSocket events from main process
|
||||
const handleConnected = () => {
|
||||
setStatus({ connected: true, state: 'CONNECTED' });
|
||||
};
|
||||
|
||||
const handleDisconnected = () => {
|
||||
setStatus({ connected: false, state: 'DISCONNECTED' });
|
||||
};
|
||||
|
||||
const handlePriceUpdate = (data: PriceIndex) => {
|
||||
setLatestPrice(data);
|
||||
};
|
||||
|
||||
const handleHistoryData = (data: HistoricalData) => {
|
||||
setHistoryData(data);
|
||||
};
|
||||
|
||||
const handleError = (error: string) => {
|
||||
setStatus((prev: WebSocketStatus) => ({ ...prev, error }));
|
||||
};
|
||||
|
||||
const handleMaxReconnect = () => {
|
||||
setStatus((prev: WebSocketStatus) => ({
|
||||
...prev,
|
||||
error: 'Maximum reconnection attempts reached. Please restart the application.'
|
||||
}));
|
||||
};
|
||||
|
||||
// Register IPC listeners
|
||||
window.electron.ipcRenderer.on('ws:connected', handleConnected);
|
||||
window.electron.ipcRenderer.on('ws:disconnected', handleDisconnected);
|
||||
window.electron.ipcRenderer.on('ws:priceUpdate', handlePriceUpdate);
|
||||
window.electron.ipcRenderer.on('ws:historyData', handleHistoryData);
|
||||
window.electron.ipcRenderer.on('ws:error', handleError);
|
||||
window.electron.ipcRenderer.on('ws:maxReconnectAttemptsReached', handleMaxReconnect);
|
||||
|
||||
// Get initial status
|
||||
window.electron.ipcRenderer.invoke('ws:getStatus').then((initialStatus) => {
|
||||
setStatus(initialStatus);
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeAllListeners('ws:connected');
|
||||
window.electron.ipcRenderer.removeAllListeners('ws:disconnected');
|
||||
window.electron.ipcRenderer.removeAllListeners('ws:priceUpdate');
|
||||
window.electron.ipcRenderer.removeAllListeners('ws:historyData');
|
||||
window.electron.ipcRenderer.removeAllListeners('ws:error');
|
||||
window.electron.ipcRenderer.removeAllListeners('ws:maxReconnectAttemptsReached');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
await window.electron.ipcRenderer.invoke('ws:connect');
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
await window.electron.ipcRenderer.invoke('ws:disconnect');
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(async (message: any) => {
|
||||
await window.electron.ipcRenderer.invoke('ws:send', message);
|
||||
}, []);
|
||||
|
||||
const requestHistory = useCallback(async (range: string) => {
|
||||
await sendMessage({ type: 'get_history', data: { range } });
|
||||
}, [sendMessage]);
|
||||
|
||||
return {
|
||||
status,
|
||||
latestPrice,
|
||||
historyData,
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
requestHistory,
|
||||
};
|
||||
}
|
||||
8
electron-app/src/renderer/index.tsx
Normal file
8
electron-app/src/renderer/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('Root element not found');
|
||||
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
3
electron-app/src/shared/types.js
Normal file
3
electron-app/src/shared/types.js
Normal file
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
// Shared types between main and renderer processes
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
43
electron-app/src/shared/types.ts
Normal file
43
electron-app/src/shared/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Shared types between main and renderer processes
|
||||
|
||||
export interface VendorPrice {
|
||||
id: string;
|
||||
platform: 'eldorado' | 'playerauctions';
|
||||
sellerName: string;
|
||||
pricePerMillion: number;
|
||||
timestamp: Date;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface PriceIndex {
|
||||
timestamp: Date;
|
||||
lowestPrice: number;
|
||||
platform: string;
|
||||
sellerName: string;
|
||||
allPrices: VendorPrice[];
|
||||
}
|
||||
|
||||
export interface PriceAlert {
|
||||
id: string;
|
||||
type: 'below' | 'above';
|
||||
threshold: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: 'price_update' | 'connection_status' | 'error' | 'history_data' | 'get_history' | 'subscribe';
|
||||
data: any;
|
||||
}
|
||||
|
||||
export interface HistoricalData {
|
||||
range: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
prices: VendorPrice[];
|
||||
}
|
||||
|
||||
export interface ConnectionStatus {
|
||||
connected: boolean;
|
||||
lastUpdate?: Date;
|
||||
error?: string;
|
||||
}
|
||||
20
electron-app/tsconfig.json
Normal file
20
electron-app/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node", "electron"],
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
8
electron-app/tsconfig.main.json
Normal file
8
electron-app/tsconfig.main.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/main",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/main/**/*", "src/shared/**/*"]
|
||||
}
|
||||
8
electron-app/tsconfig.preload.json
Normal file
8
electron-app/tsconfig.preload.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/main",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/preload.ts", "src/shared/**/*"]
|
||||
}
|
||||
14
electron-app/vite.config.mjs
Normal file
14
electron-app/vite.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: '.',
|
||||
build: {
|
||||
outDir: 'dist/renderer',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user