mirror of
https://github.com/dtzp555-max/odo.git
synced 2026-07-19 09:44:37 +00:00
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:
@@ -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 {
|
||||
|
||||
Generated
+2731
-4
File diff suppressed because it is too large
Load Diff
+22
-6
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
+15
-6
@@ -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 }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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'} · OpenClaw Dashboard
|
||||
Orchestrator
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
Vendored
+20
@@ -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;
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user