refactor: restructure to monorepo with apps and packages directories - Move companion app to apps/companion with electron-vite - Move domain/application/infrastructure to packages/ - Fix ELECTRON_RUN_AS_NODE env var issue for VS Code terminal - Remove legacy esbuild bundler (replaced by electron-vite) - Update workspace scripts in root package.json
This commit is contained in:
67
apps/companion/electron.vite.config.ts
Normal file
67
apps/companion/electron.vite.config.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { defineConfig } from 'electron-vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
build: {
|
||||
outDir: 'dist/main',
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'main/index.ts'),
|
||||
formats: ['cjs'],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'@nut-tree-fork/nut-js',
|
||||
'@nut-tree-fork/libnut',
|
||||
'@nut-tree-fork/libnut-darwin',
|
||||
'@nut-tree-fork/libnut-linux',
|
||||
'@nut-tree-fork/libnut-win32',
|
||||
'@nut-tree-fork/node-mac-permissions',
|
||||
'@nut-tree-fork/default-clipboard-provider',
|
||||
'@nut-tree-fork/provider-interfaces',
|
||||
'@nut-tree-fork/shared',
|
||||
'bufferutil',
|
||||
'utf-8-validate',
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'main.cjs',
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, '../../'),
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
build: {
|
||||
outDir: 'dist/preload',
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'main/preload.ts'),
|
||||
formats: ['cjs'],
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'preload.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: resolve(__dirname, 'renderer'),
|
||||
build: {
|
||||
outDir: resolve(__dirname, 'dist/renderer'),
|
||||
rollupOptions: {
|
||||
input: resolve(__dirname, 'renderer/index.html'),
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, '../../'),
|
||||
},
|
||||
},
|
||||
plugins: [react()],
|
||||
},
|
||||
});
|
||||
142
apps/companion/main/di-container.ts
Normal file
142
apps/companion/main/di-container.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { InMemorySessionRepository } from '@/packages/infrastructure/repositories/InMemorySessionRepository';
|
||||
import { MockBrowserAutomationAdapter } from '@/packages/infrastructure/adapters/automation/MockBrowserAutomationAdapter';
|
||||
import { BrowserDevToolsAdapter } from '@/packages/infrastructure/adapters/automation/BrowserDevToolsAdapter';
|
||||
import { NutJsAutomationAdapter } from '@/packages/infrastructure/adapters/automation/NutJsAutomationAdapter';
|
||||
import { MockAutomationEngineAdapter } from '@/packages/infrastructure/adapters/automation/MockAutomationEngineAdapter';
|
||||
import { StartAutomationSessionUseCase } from '@/packages/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { loadAutomationConfig, AutomationMode } from '@/packages/infrastructure/config';
|
||||
import type { ISessionRepository } from '@/packages/application/ports/ISessionRepository';
|
||||
import type { IBrowserAutomation } from '@/packages/application/ports/IBrowserAutomation';
|
||||
import type { IAutomationEngine } from '@/packages/application/ports/IAutomationEngine';
|
||||
|
||||
export interface BrowserConnectionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create browser automation adapter based on configuration mode.
|
||||
*
|
||||
* @param mode - The automation mode from configuration
|
||||
* @returns IBrowserAutomation adapter instance
|
||||
*/
|
||||
function createBrowserAutomationAdapter(mode: AutomationMode): IBrowserAutomation {
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
switch (mode) {
|
||||
case 'dev':
|
||||
return new BrowserDevToolsAdapter({
|
||||
debuggingPort: config.devTools?.debuggingPort,
|
||||
browserWSEndpoint: config.devTools?.browserWSEndpoint,
|
||||
defaultTimeout: config.defaultTimeout,
|
||||
});
|
||||
|
||||
case 'production':
|
||||
return new NutJsAutomationAdapter({
|
||||
mouseSpeed: config.nutJs?.mouseSpeed,
|
||||
keyboardDelay: config.nutJs?.keyboardDelay,
|
||||
defaultTimeout: config.defaultTimeout,
|
||||
});
|
||||
|
||||
case 'mock':
|
||||
default:
|
||||
return new MockBrowserAutomationAdapter();
|
||||
}
|
||||
}
|
||||
|
||||
export class DIContainer {
|
||||
private static instance: DIContainer;
|
||||
|
||||
private sessionRepository: ISessionRepository;
|
||||
private browserAutomation: IBrowserAutomation;
|
||||
private automationEngine: IAutomationEngine;
|
||||
private startAutomationUseCase: StartAutomationSessionUseCase;
|
||||
private automationMode: AutomationMode;
|
||||
|
||||
private constructor() {
|
||||
const config = loadAutomationConfig();
|
||||
this.automationMode = config.mode;
|
||||
|
||||
this.sessionRepository = new InMemorySessionRepository();
|
||||
this.browserAutomation = createBrowserAutomationAdapter(config.mode);
|
||||
this.automationEngine = new MockAutomationEngineAdapter(
|
||||
this.browserAutomation,
|
||||
this.sessionRepository
|
||||
);
|
||||
this.startAutomationUseCase = new StartAutomationSessionUseCase(
|
||||
this.automationEngine,
|
||||
this.browserAutomation,
|
||||
this.sessionRepository
|
||||
);
|
||||
}
|
||||
|
||||
public static getInstance(): DIContainer {
|
||||
if (!DIContainer.instance) {
|
||||
DIContainer.instance = new DIContainer();
|
||||
}
|
||||
return DIContainer.instance;
|
||||
}
|
||||
|
||||
public getStartAutomationUseCase(): StartAutomationSessionUseCase {
|
||||
return this.startAutomationUseCase;
|
||||
}
|
||||
|
||||
public getSessionRepository(): ISessionRepository {
|
||||
return this.sessionRepository;
|
||||
}
|
||||
|
||||
public getAutomationEngine(): IAutomationEngine {
|
||||
return this.automationEngine;
|
||||
}
|
||||
|
||||
public getAutomationMode(): AutomationMode {
|
||||
return this.automationMode;
|
||||
}
|
||||
|
||||
public getBrowserAutomation(): IBrowserAutomation {
|
||||
return this.browserAutomation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize browser connection for dev mode.
|
||||
* In dev mode, connects to the browser via Chrome DevTools Protocol.
|
||||
* In mock mode, returns success immediately (no connection needed).
|
||||
*/
|
||||
public async initializeBrowserConnection(): Promise<BrowserConnectionResult> {
|
||||
if (this.automationMode === 'dev') {
|
||||
try {
|
||||
const devToolsAdapter = this.browserAutomation as BrowserDevToolsAdapter;
|
||||
await devToolsAdapter.connect();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to connect to browser'
|
||||
};
|
||||
}
|
||||
}
|
||||
if (this.automationMode === 'production') {
|
||||
try {
|
||||
const nutJsAdapter = this.browserAutomation as NutJsAutomationAdapter;
|
||||
const result = await nutJsAdapter.connect();
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to initialize nut.js'
|
||||
};
|
||||
}
|
||||
}
|
||||
return { success: true }; // Mock mode doesn't need connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing with different configurations).
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
DIContainer.instance = undefined as unknown as DIContainer;
|
||||
}
|
||||
}
|
||||
41
apps/companion/main/index.ts
Normal file
41
apps/companion/main/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { setupIpcHandlers } from './ipc-handlers';
|
||||
|
||||
function createWindow() {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
mainWindow.loadURL('http://localhost:5173');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
// Path from dist/main to dist/renderer
|
||||
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
|
||||
}
|
||||
|
||||
setupIpcHandlers(mainWindow);
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
111
apps/companion/main/ipc-handlers.ts
Normal file
111
apps/companion/main/ipc-handlers.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron';
|
||||
import { DIContainer } from './di-container';
|
||||
import type { HostedSessionConfig } from '@/packages/domain/entities/HostedSessionConfig';
|
||||
import { StepId } from '@/packages/domain/value-objects/StepId';
|
||||
import { MockAutomationEngineAdapter } from '@/packages/infrastructure/adapters/automation/MockAutomationEngineAdapter';
|
||||
|
||||
export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
const container = DIContainer.getInstance();
|
||||
const startAutomationUseCase = container.getStartAutomationUseCase();
|
||||
const sessionRepository = container.getSessionRepository();
|
||||
const automationEngine = container.getAutomationEngine();
|
||||
|
||||
ipcMain.handle('start-automation', async (_event: IpcMainInvokeEvent, config: HostedSessionConfig) => {
|
||||
try {
|
||||
// Connect to browser first (required for dev mode)
|
||||
const connectionResult = await container.initializeBrowserConnection();
|
||||
if (!connectionResult.success) {
|
||||
return { success: false, error: connectionResult.error };
|
||||
}
|
||||
|
||||
const result = await startAutomationUseCase.execute(config);
|
||||
const session = await sessionRepository.findById(result.sessionId);
|
||||
|
||||
if (session) {
|
||||
// Start the automation by executing step 1
|
||||
await automationEngine.executeStep(StepId.create(1), config);
|
||||
}
|
||||
|
||||
// Set up progress monitoring
|
||||
const checkInterval = setInterval(async () => {
|
||||
const updatedSession = await sessionRepository.findById(result.sessionId);
|
||||
if (!updatedSession) {
|
||||
clearInterval(checkInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.webContents.send('session-progress', {
|
||||
sessionId: result.sessionId,
|
||||
currentStep: updatedSession.currentStep.value,
|
||||
state: updatedSession.state.value,
|
||||
completedSteps: Array.from({ length: updatedSession.currentStep.value - 1 }, (_, i) => i + 1),
|
||||
hasError: updatedSession.errorMessage !== undefined,
|
||||
errorMessage: updatedSession.errorMessage || null
|
||||
});
|
||||
|
||||
if (updatedSession.state.value === 'COMPLETED' ||
|
||||
updatedSession.state.value === 'FAILED' ||
|
||||
updatedSession.state.value === 'STOPPED_AT_STEP_18') {
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionId: result.sessionId
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-session-status', async (_event: IpcMainInvokeEvent, sessionId: string) => {
|
||||
const session = await sessionRepository.findById(sessionId);
|
||||
if (!session) {
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
currentStep: session.currentStep.value,
|
||||
state: session.state.value,
|
||||
completedSteps: Array.from({ length: session.currentStep.value - 1 }, (_, i) => i + 1),
|
||||
hasError: session.errorMessage !== undefined,
|
||||
errorMessage: session.errorMessage || null
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('pause-automation', async (_event: IpcMainInvokeEvent, _sessionId: string) => {
|
||||
return { success: false, error: 'Pause not implemented in POC' };
|
||||
});
|
||||
|
||||
ipcMain.handle('resume-automation', async (_event: IpcMainInvokeEvent, _sessionId: string) => {
|
||||
return { success: false, error: 'Resume not implemented in POC' };
|
||||
});
|
||||
|
||||
ipcMain.handle('stop-automation', async (_event: IpcMainInvokeEvent, sessionId: string) => {
|
||||
try {
|
||||
// Stop the automation engine interval
|
||||
const engine = automationEngine as MockAutomationEngineAdapter;
|
||||
engine.stopAutomation();
|
||||
|
||||
// Update session state to failed with user stop reason
|
||||
const session = await sessionRepository.findById(sessionId);
|
||||
if (session) {
|
||||
session.fail('User stopped automation');
|
||||
await sessionRepository.update(session);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
22
apps/companion/main/preload.ts
Normal file
22
apps/companion/main/preload.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
|
||||
|
||||
export interface ElectronAPI {
|
||||
startAutomation: (config: HostedSessionConfig) => Promise<{ success: boolean; sessionId?: string; error?: string }>;
|
||||
stopAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getSessionStatus: (sessionId: string) => Promise<any>;
|
||||
pauseAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
resumeAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
onSessionProgress: (callback: (progress: any) => void) => void;
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
startAutomation: (config: HostedSessionConfig) => ipcRenderer.invoke('start-automation', config),
|
||||
stopAutomation: (sessionId: string) => ipcRenderer.invoke('stop-automation', sessionId),
|
||||
getSessionStatus: (sessionId: string) => ipcRenderer.invoke('get-session-status', sessionId),
|
||||
pauseAutomation: (sessionId: string) => ipcRenderer.invoke('pause-automation', sessionId),
|
||||
resumeAutomation: (sessionId: string) => ipcRenderer.invoke('resume-automation', sessionId),
|
||||
onSessionProgress: (callback: (progress: any) => void) => {
|
||||
ipcRenderer.on('session-progress', (_event, progress) => callback(progress));
|
||||
}
|
||||
} as ElectronAPI);
|
||||
29
apps/companion/package.json
Normal file
29
apps/companion/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@gridpilot/companion",
|
||||
"version": "0.1.0",
|
||||
"description": "GridPilot Companion - Electron app for iRacing automation",
|
||||
"main": "dist/main/main.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "unset ELECTRON_RUN_AS_NODE && electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"preview": "unset ELECTRON_RUN_AS_NODE && electron-vite preview",
|
||||
"start": "unset ELECTRON_RUN_AS_NODE && electron ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"electron": "^28.3.3",
|
||||
"electron-vite": "^2.3.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nut-tree-fork/nut-js": "^4.2.6",
|
||||
"puppeteer-core": "^24.31.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
}
|
||||
110
apps/companion/renderer/App.tsx
Normal file
110
apps/companion/renderer/App.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SessionCreationForm } from './components/SessionCreationForm';
|
||||
import { SessionProgressMonitor } from './components/SessionProgressMonitor';
|
||||
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
|
||||
|
||||
interface SessionProgress {
|
||||
sessionId: string;
|
||||
currentStep: number;
|
||||
state: string;
|
||||
completedSteps: number[];
|
||||
hasError: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<SessionProgress | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.onSessionProgress((newProgress: SessionProgress) => {
|
||||
setProgress(newProgress);
|
||||
if (newProgress.state === 'COMPLETED' ||
|
||||
newProgress.state === 'FAILED' ||
|
||||
newProgress.state === 'STOPPED_AT_STEP_18') {
|
||||
setIsRunning(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStartAutomation = async (config: HostedSessionConfig) => {
|
||||
setIsRunning(true);
|
||||
const result = await window.electronAPI.startAutomation(config);
|
||||
|
||||
if (result.success && result.sessionId) {
|
||||
setSessionId(result.sessionId);
|
||||
} else {
|
||||
setIsRunning(false);
|
||||
alert(`Failed to start automation: ${result.error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopAutomation = async () => {
|
||||
if (sessionId) {
|
||||
const result = await window.electronAPI.stopAutomation(sessionId);
|
||||
if (result.success) {
|
||||
setIsRunning(false);
|
||||
setProgress(prev => prev ? { ...prev, state: 'STOPPED', hasError: false, errorMessage: 'User stopped automation' } : null);
|
||||
} else {
|
||||
alert(`Failed to stop automation: ${result.error}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#1a1a1a'
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '2rem',
|
||||
borderRight: '1px solid #333'
|
||||
}}>
|
||||
<h1 style={{ marginBottom: '2rem', color: '#fff' }}>
|
||||
GridPilot Companion
|
||||
</h1>
|
||||
<p style={{ marginBottom: '2rem', color: '#aaa' }}>
|
||||
Hosted Session Automation POC
|
||||
</p>
|
||||
<SessionCreationForm
|
||||
onSubmit={handleStartAutomation}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={handleStopAutomation}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: '#dc3545',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
Stop Automation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '2rem',
|
||||
backgroundColor: '#0d0d0d'
|
||||
}}>
|
||||
<SessionProgressMonitor
|
||||
sessionId={sessionId}
|
||||
progress={progress}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
apps/companion/renderer/components/SessionCreationForm.tsx
Normal file
177
apps/companion/renderer/components/SessionCreationForm.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { HostedSessionConfig } from '../../../../packages/domain/entities/HostedSessionConfig';
|
||||
|
||||
interface SessionCreationFormProps {
|
||||
onSubmit: (config: HostedSessionConfig) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function SessionCreationForm({ onSubmit, disabled }: SessionCreationFormProps) {
|
||||
const [config, setConfig] = useState<HostedSessionConfig>({
|
||||
sessionName: 'POC Test Session',
|
||||
serverName: 'POC Server',
|
||||
password: 'test123',
|
||||
adminPassword: 'admin123',
|
||||
maxDrivers: 40,
|
||||
trackId: 'watkins-glen',
|
||||
carIds: ['porsche-911-gt3-r'],
|
||||
weatherType: 'dynamic',
|
||||
timeOfDay: 'afternoon',
|
||||
sessionDuration: 60,
|
||||
practiceLength: 15,
|
||||
qualifyingLength: 10,
|
||||
warmupLength: 5,
|
||||
raceLength: 30,
|
||||
startType: 'standing',
|
||||
restarts: 'single-file',
|
||||
damageModel: 'realistic',
|
||||
trackState: 'auto'
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(config);
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
backgroundColor: '#2a2a2a',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '4px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '14px'
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
marginBottom: '0.5rem',
|
||||
color: '#aaa',
|
||||
fontSize: '14px'
|
||||
};
|
||||
|
||||
const fieldStyle: React.CSSProperties = {
|
||||
marginBottom: '1rem'
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ maxWidth: '500px' }}>
|
||||
<h2 style={{ marginBottom: '1.5rem', color: '#fff' }}>Session Configuration</h2>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Session Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.sessionName}
|
||||
onChange={(e) => setConfig({ ...config, sessionName: e.target.value })}
|
||||
style={inputStyle}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Server Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.serverName}
|
||||
onChange={(e) => setConfig({ ...config, serverName: e.target.value })}
|
||||
style={inputStyle}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Max Drivers</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.maxDrivers}
|
||||
onChange={(e) => setConfig({ ...config, maxDrivers: parseInt(e.target.value) })}
|
||||
style={inputStyle}
|
||||
disabled={disabled}
|
||||
min="1"
|
||||
max="60"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Track</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.trackId}
|
||||
onChange={(e) => setConfig({ ...config, trackId: e.target.value })}
|
||||
style={inputStyle}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Weather Type</label>
|
||||
<select
|
||||
value={config.weatherType}
|
||||
onChange={(e) => setConfig({ ...config, weatherType: e.target.value as any })}
|
||||
style={inputStyle}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="static">Static</option>
|
||||
<option value="dynamic">Dynamic</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Time of Day</label>
|
||||
<select
|
||||
value={config.timeOfDay}
|
||||
onChange={(e) => setConfig({ ...config, timeOfDay: e.target.value as any })}
|
||||
style={inputStyle}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="morning">Morning</option>
|
||||
<option value="afternoon">Afternoon</option>
|
||||
<option value="evening">Evening</option>
|
||||
<option value="night">Night</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Race Length (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.raceLength}
|
||||
onChange={(e) => setConfig({ ...config, raceLength: parseInt(e.target.value) })}
|
||||
style={inputStyle}
|
||||
disabled={disabled}
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
backgroundColor: disabled ? '#444' : '#0066cc',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
marginTop: '1rem'
|
||||
}}
|
||||
>
|
||||
{disabled ? 'Automation Running...' : 'Start Automation'}
|
||||
</button>
|
||||
|
||||
<p style={{ marginTop: '1rem', fontSize: '12px', color: '#888', lineHeight: '1.5' }}>
|
||||
Note: This POC will execute steps 1-18 and stop at step 18 (Track Conditions) for safety.
|
||||
The actual session creation will not be completed.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
208
apps/companion/renderer/components/SessionProgressMonitor.tsx
Normal file
208
apps/companion/renderer/components/SessionProgressMonitor.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SessionProgress {
|
||||
sessionId: string;
|
||||
currentStep: number;
|
||||
state: string;
|
||||
completedSteps: number[];
|
||||
hasError: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
interface SessionProgressMonitorProps {
|
||||
sessionId: string | null;
|
||||
progress: SessionProgress | null;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
const STEP_NAMES: { [key: number]: string } = {
|
||||
1: 'Navigate to Hosted Racing',
|
||||
2: 'Click Create a Race',
|
||||
3: 'Fill Race Information',
|
||||
4: 'Configure Server Details',
|
||||
5: 'Set Admins',
|
||||
6: 'Add Admin',
|
||||
7: 'Set Time Limits',
|
||||
8: 'Set Cars',
|
||||
9: 'Add Car',
|
||||
10: 'Set Car Classes',
|
||||
11: 'Set Track',
|
||||
12: 'Add Track',
|
||||
13: 'Configure Track Options',
|
||||
14: 'Set Time of Day',
|
||||
15: 'Configure Weather',
|
||||
16: 'Set Race Options',
|
||||
17: 'Configure Team Driving',
|
||||
18: 'Set Track Conditions'
|
||||
};
|
||||
|
||||
export function SessionProgressMonitor({ sessionId, progress, isRunning }: SessionProgressMonitorProps) {
|
||||
const getStateColor = (state: string) => {
|
||||
switch (state) {
|
||||
case 'IN_PROGRESS': return '#0066cc';
|
||||
case 'COMPLETED': return '#28a745';
|
||||
case 'FAILED': return '#dc3545';
|
||||
case 'STOPPED_AT_STEP_18': return '#ffc107';
|
||||
default: return '#6c757d';
|
||||
}
|
||||
};
|
||||
|
||||
const getStateLabel = (state: string) => {
|
||||
switch (state) {
|
||||
case 'IN_PROGRESS': return 'Running';
|
||||
case 'COMPLETED': return 'Completed';
|
||||
case 'FAILED': return 'Failed';
|
||||
case 'STOPPED_AT_STEP_18': return 'Stopped at Step 18';
|
||||
default: return state;
|
||||
}
|
||||
};
|
||||
|
||||
if (!sessionId && !isRunning) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', color: '#666', paddingTop: '4rem' }}>
|
||||
<h2 style={{ marginBottom: '1rem' }}>Session Progress</h2>
|
||||
<p>Configure and start an automation session to see progress here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '1.5rem', color: '#fff' }}>Session Progress</h2>
|
||||
|
||||
{sessionId && (
|
||||
<div style={{
|
||||
marginBottom: '2rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #333'
|
||||
}}>
|
||||
<div style={{ marginBottom: '0.5rem', color: '#aaa', fontSize: '14px' }}>
|
||||
Session ID
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '12px', color: '#fff' }}>
|
||||
{sessionId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<>
|
||||
<div style={{
|
||||
marginBottom: '2rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #333'
|
||||
}}>
|
||||
<div style={{ marginBottom: '0.5rem', color: '#aaa', fontSize: '14px' }}>
|
||||
Status
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: getStateColor(progress.state)
|
||||
}}>
|
||||
{getStateLabel(progress.state)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{progress.state === 'STOPPED_AT_STEP_18' && (
|
||||
<div style={{
|
||||
marginBottom: '2rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: '#332b00',
|
||||
border: '1px solid #ffc107',
|
||||
borderRadius: '4px',
|
||||
color: '#ffc107'
|
||||
}}>
|
||||
<strong>⚠️ Safety Stop</strong>
|
||||
<p style={{ marginTop: '0.5rem', fontSize: '14px', lineHeight: '1.5' }}>
|
||||
Automation stopped at step 18 (Track Conditions) as configured.
|
||||
This prevents accidental session creation during POC demonstration.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress.hasError && progress.errorMessage && (
|
||||
<div style={{
|
||||
marginBottom: '2rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: '#2d0a0a',
|
||||
border: '1px solid #dc3545',
|
||||
borderRadius: '4px',
|
||||
color: '#dc3545'
|
||||
}}>
|
||||
<strong>Error</strong>
|
||||
<p style={{ marginTop: '0.5rem', fontSize: '14px' }}>
|
||||
{progress.errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '1rem', color: '#aaa', fontSize: '14px' }}>
|
||||
Progress: {progress.completedSteps.length} / 18 steps
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{Object.entries(STEP_NAMES).map(([stepNum, stepName]) => {
|
||||
const step = parseInt(stepNum);
|
||||
const isCompleted = progress.completedSteps.includes(step);
|
||||
const isCurrent = progress.currentStep === step;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
backgroundColor: isCurrent ? '#1a3a1a' : isCompleted ? '#0d2b0d' : '#1a1a1a',
|
||||
border: `1px solid ${isCurrent ? '#28a745' : isCompleted ? '#1d4d1d' : '#333'}`,
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isCompleted ? '#28a745' : isCurrent ? '#0066cc' : '#333',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{isCompleted ? '✓' : step}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
color: isCurrent ? '#28a745' : isCompleted ? '#aaa' : '#666',
|
||||
fontSize: '14px',
|
||||
fontWeight: isCurrent ? 'bold' : 'normal'
|
||||
}}>
|
||||
{stepName}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#0066cc',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
IN PROGRESS
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
apps/companion/renderer/index.css
Normal file
19
apps/companion/renderer/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
12
apps/companion/renderer/index.html
Normal file
12
apps/companion/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GridPilot - Hosted Session Automation POC</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
apps/companion/renderer/main.tsx
Normal file
10
apps/companion/renderer/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
9
apps/companion/renderer/types.d.ts
vendored
Normal file
9
apps/companion/renderer/types.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ElectronAPI } from '../main/preload';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user