feat: Phase 1 scaffold — React + Vite + TypeScript + Dashboard (v0.10.0)

- Add Vite + React + TypeScript + Tailwind CSS to Electron app
- Create Dashboard page with gateway control (start/stop/restart/status)
- Expand preload.js IPC bridge with gateway and OCM API channels
- Add IPC handlers in main.js for openclaw CLI and OCM proxy
- Dev mode loads Vite dev server, production loads built dist/
- Bump version to 0.10.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 07:30:27 +10:00
co-authored by Claude Opus 4.6
parent b74f9942dd
commit cdcf28ff4e
14 changed files with 3150 additions and 21 deletions
+67 -5
View File
@@ -1,8 +1,12 @@
'use strict';
const { app, BrowserWindow, Tray, Menu, nativeImage, dialog } = require('electron');
const { app, BrowserWindow, Tray, Menu, nativeImage, dialog, ipcMain } = require('electron');
const path = require('path');
const { fork } = require('child_process');
const { fork, execFile } = require('child_process');
const http = require('http');
// ── Dev Mode Detection ──────────────────────────────────────
const isDev = !app.isPackaged;
// ── State ────────────────────────────────────────────────────
let mainWindow = null;
@@ -58,7 +62,7 @@ function startServer() {
}).then(({ response }) => {
if (response === 0) {
startServer().then(() => {
if (mainWindow) mainWindow.loadURL(`http://127.0.0.1:${serverPort}`);
if (mainWindow) mainWindow.reload();
});
} else {
app.quit();
@@ -102,7 +106,12 @@ function createWindow() {
}
});
mainWindow.loadURL(`http://127.0.0.1:${serverPort}`);
// Load the React UI (Vite dev server in dev, built files in production)
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
} else {
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'));
}
mainWindow.once('ready-to-show', () => {
mainWindow.show();
@@ -166,7 +175,7 @@ function createTray() {
click: async () => {
stopServer();
await startServer();
if (mainWindow) mainWindow.loadURL(`http://127.0.0.1:${serverPort}`);
if (mainWindow) mainWindow.reload();
}
},
{ type: 'separator' },
@@ -202,6 +211,59 @@ function createTray() {
});
}
// ── IPC Handlers ────────────────────────────────────────────
function runOpenclawCommand(args) {
return new Promise((resolve, reject) => {
execFile('openclaw', args, { timeout: 30000 }, (error, stdout, stderr) => {
if (error) {
resolve(stderr || error.message);
} else {
resolve(stdout || 'OK');
}
});
});
}
function proxyOcmApi(method, urlPath, body) {
return new Promise((resolve, reject) => {
const options = {
hostname: '127.0.0.1',
port: serverPort,
path: urlPath,
method: method.toUpperCase(),
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch {
resolve(data);
}
});
});
req.on('error', (err) => reject(err.message));
if (body && method.toUpperCase() !== 'GET') {
req.write(JSON.stringify(body));
}
req.end();
});
}
ipcMain.handle('gateway:status', () => runOpenclawCommand(['gateway', 'status']));
ipcMain.handle('gateway:start', () => runOpenclawCommand(['gateway', 'start']));
ipcMain.handle('gateway:stop', () => runOpenclawCommand(['gateway', 'stop']));
ipcMain.handle('gateway:restart', () => runOpenclawCommand(['gateway', 'restart']));
ipcMain.handle('ocm:api', (_event, { method, path: urlPath, body }) =>
proxyOcmApi(method, urlPath, body)
);
// ── App Lifecycle ────────────────────────────────────────────
app.whenReady().then(async () => {
try {
+2731 -4
View File
File diff suppressed because it is too large Load Diff
+22 -6
View File
@@ -1,14 +1,16 @@
{
"name": "odo",
"version": "0.9.6",
"version": "0.10.0",
"description": "ODO — OpenClaw Dashboard Orchestrator",
"main": "main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder",
"build:mac": "electron-builder --mac",
"build:win": "electron-builder --win",
"build:linux": "electron-builder --linux"
"dev": "concurrently -k \"vite --config vite.config.ts\" \"sleep 3 && electron .\"",
"build:renderer": "vite build --config vite.config.ts",
"build": "npm run build:renderer && electron-builder",
"build:mac": "npm run build:renderer && electron-builder --mac",
"build:win": "npm run build:renderer && electron-builder --win",
"build:linux": "npm run build:renderer && electron-builder --linux"
},
"build": {
"appId": "com.openclaw.odo",
@@ -17,6 +19,7 @@
"main.js",
"preload.js",
"server/openclaw-manager.js",
"dist/**/*",
"assets/**/*"
],
"mac": {
@@ -40,8 +43,21 @@
},
"author": "",
"license": "MIT",
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"electron": "^33.0.0",
"electron-builder": "^25.0.0"
"electron-builder": "^25.0.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+15 -6
View File
@@ -1,12 +1,21 @@
'use strict';
// Preload script — runs in renderer before page loads
// Currently minimal; will be expanded for IPC bridge in future phases
const { contextBridge } = require('electron');
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('odo', {
platform: process.platform,
version: '0.9.6',
isElectron: true
version: '0.10.0',
isElectron: true,
gateway: {
status: () => ipcRenderer.invoke('gateway:status'),
start: () => ipcRenderer.invoke('gateway:start'),
stop: () => ipcRenderer.invoke('gateway:stop'),
restart: () => ipcRenderer.invoke('gateway:restart'),
},
ocm: {
api: (method, path, body) =>
ipcRenderer.invoke('ocm:api', { method, path, body }),
},
});
+8
View File
@@ -0,0 +1,8 @@
import Dashboard from './pages/Dashboard';
function App() {
// Router placeholder — will add react-router in a future phase
return <Dashboard />;
}
export default App;
+30
View File
@@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar styling for dark theme */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #0f1117;
}
::-webkit-scrollbar-thumb {
background: #2a2d3a;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #3a3d4a;
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://localhost:*" />
<title>ODO</title>
</head>
<body class="bg-odo-bg text-odo-text">
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+10
View 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>
);
+159
View File
@@ -0,0 +1,159 @@
import { useState, useEffect, useCallback } from 'react';
type GatewayStatus = 'unknown' | 'checking' | 'running' | 'stopped' | 'error';
function Dashboard() {
const [gwStatus, setGwStatus] = useState<GatewayStatus>('unknown');
const [gwOutput, setGwOutput] = useState('');
const [actionInProgress, setActionInProgress] = useState(false);
const isElectron = typeof window !== 'undefined' && window.odo?.isElectron;
const checkStatus = useCallback(async () => {
if (!isElectron) {
setGwStatus('unknown');
setGwOutput('Not running in Electron');
return;
}
setGwStatus('checking');
try {
const result = await window.odo.gateway.status();
// Parse the output to determine status
if (result.toLowerCase().includes('running') || result.toLowerCase().includes('active')) {
setGwStatus('running');
} else {
setGwStatus('stopped');
}
setGwOutput(result);
} catch (err) {
setGwStatus('error');
setGwOutput(String(err));
}
}, [isElectron]);
useEffect(() => {
checkStatus();
// Poll every 15 seconds
const interval = setInterval(checkStatus, 15000);
return () => clearInterval(interval);
}, [checkStatus]);
const handleAction = async (action: 'start' | 'stop' | 'restart') => {
if (!isElectron) return;
setActionInProgress(true);
try {
const result = await window.odo.gateway[action]();
setGwOutput(result);
// Re-check status after action
setTimeout(checkStatus, 1000);
} catch (err) {
setGwOutput(String(err));
} finally {
setActionInProgress(false);
}
};
const statusColor: Record<GatewayStatus, string> = {
unknown: 'bg-gray-500',
checking: 'bg-yellow-500 animate-pulse',
running: 'bg-green-500',
stopped: 'bg-red-500',
error: 'bg-red-700',
};
return (
<div className="min-h-screen bg-odo-bg p-6">
{/* Header */}
<header className="mb-8">
<h1 className="text-3xl font-bold text-white tracking-tight">ODO</h1>
<p className="text-odo-text-dim text-sm mt-1">
OpenClaw Dashboard Orchestrator
</p>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Gateway Control */}
<section className="bg-odo-surface border border-odo-border rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">
Gateway Control
</h2>
{/* Status indicator */}
<div className="flex items-center gap-3 mb-4">
<span
className={`inline-block w-3 h-3 rounded-full ${statusColor[gwStatus]}`}
/>
<span className="text-odo-text capitalize">{gwStatus}</span>
</div>
{/* Action buttons */}
<div className="flex gap-3 mb-4">
<button
onClick={() => handleAction('start')}
disabled={actionInProgress || !isElectron}
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded text-sm font-medium transition-colors"
>
Start
</button>
<button
onClick={() => handleAction('stop')}
disabled={actionInProgress || !isElectron}
className="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded text-sm font-medium transition-colors"
>
Stop
</button>
<button
onClick={() => handleAction('restart')}
disabled={actionInProgress || !isElectron}
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded text-sm font-medium transition-colors"
>
Restart
</button>
<button
onClick={checkStatus}
disabled={!isElectron}
className="px-4 py-2 bg-odo-border hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded text-sm font-medium transition-colors"
>
Refresh
</button>
</div>
{/* Output */}
{gwOutput && (
<pre className="bg-odo-bg border border-odo-border rounded p-3 text-xs text-odo-text-dim overflow-x-auto whitespace-pre-wrap max-h-40">
{gwOutput}
</pre>
)}
</section>
{/* Agent Overview (placeholder) */}
<section className="bg-odo-surface border border-odo-border rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">
Agent Overview
</h2>
<p className="text-odo-text-dim text-sm">
Agent management will be available in a future update.
</p>
</section>
{/* Stats Summary (placeholder) */}
<section className="bg-odo-surface border border-odo-border rounded-lg p-6 lg:col-span-2">
<h2 className="text-lg font-semibold text-white mb-4">
Stats Summary
</h2>
<p className="text-odo-text-dim text-sm">
System statistics and metrics will be available in a future update.
</p>
</section>
</div>
{/* Footer */}
<footer className="mt-8 text-center text-odo-text-dim text-xs">
ODO v{window.odo?.version ?? '0.10.0'} &middot; OpenClaw Dashboard
Orchestrator
</footer>
</div>
);
}
export default Dashboard;
+20
View File
@@ -0,0 +1,20 @@
/// <reference types="vite/client" />
interface OdoAPI {
platform: string;
version: string;
isElectron: boolean;
gateway: {
status: () => Promise<string>;
start: () => Promise<string>;
stop: () => Promise<string>;
restart: () => Promise<string>;
};
ocm: {
api: (method: string, path: string, body?: unknown) => Promise<unknown>;
};
}
interface Window {
odo: OdoAPI;
}
+20
View File
@@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
odo: {
bg: '#0f1117',
surface: '#1a1d27',
border: '#2a2d3a',
accent: '#6366f1',
'accent-hover': '#818cf8',
text: '#e2e8f0',
'text-dim': '#94a3b8',
},
},
},
},
plugins: [],
};
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
root: 'src',
base: './',
build: {
outDir: '../dist',
emptyOutDir: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
strictPort: true,
},
});