feat: Implement recipe selection with backend integration

Backend changes (C#):
- Add SelectRecipe method to MachineBridge for recipe selection
- Add currentRecipeId tracking in MainForm
- Implement SELECT_RECIPE handler in WebSocketServer

Frontend changes (React/TypeScript):
- Add selectRecipe method to communication layer
- Update handleSelectRecipe to call backend and handle response
- Recipe selection updates ModelInfoPanel automatically
- Add error handling and logging for recipe operations

Layout improvements:
- Add Layout component with persistent Header and Footer
- Create separate IOMonitorPage for full-screen I/O monitoring
- Add dynamic IO tab switcher in Header (Inputs/Outputs)
- Ensure consistent UI across all pages

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-24 22:42:00 +09:00
parent 8dc6b0f921
commit 82cf4b8fd0
17 changed files with 1138 additions and 286 deletions

View File

@@ -41,9 +41,25 @@ namespace HMIWeb
_host.HandleCommand(command);
}
public void LoadRecipe(string recipeId)
public string SelectRecipe(string recipeId)
{
Console.WriteLine($"[C#] Loading Recipe: {recipeId}");
Console.WriteLine($"[C#] Selecting Recipe: {recipeId}");
// In real app, load recipe settings here
// For now, just return success
try
{
// Simulate recipe loading logic
_host.SetCurrentRecipe(recipeId);
var response = new { success = true, message = "Recipe selected successfully", recipeId = recipeId };
return JsonConvert.SerializeObject(response);
}
catch (Exception ex)
{
var response = new { success = false, message = ex.Message };
return JsonConvert.SerializeObject(response);
}
}
public string GetConfig()
@@ -93,5 +109,49 @@ namespace HMIWeb
Console.WriteLine($"[Backend] Data: {configJson}");
// In a real app, we would save this to a file or database
}
public string GetIOList()
{
var ioList = new System.Collections.Generic.List<object>();
// Outputs (0-31)
for (int i = 0; i < 32; i++)
{
string name = $"DOUT_{i:D2}";
if (i == 0) name = "Tower Lamp Red";
if (i == 1) name = "Tower Lamp Yel";
if (i == 2) name = "Tower Lamp Grn";
ioList.Add(new { id = i, name = name, type = "output", state = false });
}
// Inputs (0-31)
for (int i = 0; i < 32; i++)
{
string name = $"DIN_{i:D2}";
bool initialState = false;
if (i == 0) name = "Front Door Sensor";
if (i == 1) name = "Right Door Sensor";
if (i == 2) name = "Left Door Sensor";
if (i == 3) name = "Back Door Sensor";
if (i == 4) { name = "Main Air Pressure"; initialState = true; }
if (i == 5) { name = "Vacuum Generator"; initialState = true; }
if (i == 6) { name = "Emergency Stop Loop"; initialState = true; }
ioList.Add(new { id = i, name = name, type = "input", state = initialState });
}
return Newtonsoft.Json.JsonConvert.SerializeObject(ioList);
}
public string GetRecipeList()
{
var recipes = new System.Collections.Generic.List<object>();
recipes.Add(new { id = "1", name = "Wafer_Proc_300_Au", lastModified = "2023-10-25" });
recipes.Add(new { id = "2", name = "Wafer_Insp_200_Adv", lastModified = "2023-10-26" });
recipes.Add(new { id = "3", name = "Glass_Gen5_Bonding", lastModified = "2023-10-27" });
recipes.Add(new { id = "4", name = "Solar_Cell_Cut_A", lastModified = "2023-11-01" });
recipes.Add(new { id = "5", name = "LED_Mount_HighSpeed", lastModified = "2023-11-15" });
return Newtonsoft.Json.JsonConvert.SerializeObject(recipes);
}
}
}

View File

@@ -25,6 +25,7 @@ namespace HMIWeb
private bool[] inputs = new bool[32];
private bool[] outputs = new bool[32];
private string systemState = "IDLE";
private string currentRecipeId = "1"; // Default recipe
public MainForm()
{
InitializeComponent();
@@ -77,7 +78,7 @@ namespace HMIWeb
webView.CoreWebView2.AddHostObjectToScript("machine", new MachineBridge(this));
// 3. Load UI
//webView.Source = new Uri("http://hmi.local/index.html");
webView.Source = new Uri("http://hmi.local/index.html");
// Disable default browser keys (F5, F12 etc) if needed for kiosk mode
webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
@@ -153,6 +154,18 @@ namespace HMIWeb
systemState = (cmd == "START") ? "RUNNING" : (cmd == "STOP") ? "PAUSED" : "IDLE";
}
public void SetCurrentRecipe(string recipeId)
{
currentRecipeId = recipeId;
Console.WriteLine($"[MainForm] Current recipe set to: {recipeId}");
// In real app, load recipe parameters here
}
public string GetCurrentRecipe()
{
return currentRecipeId;
}
private double Lerp(double a, double b, double t) => a + (b - a) * t;
}
}

View File

@@ -120,6 +120,7 @@ namespace HMIWeb
dynamic json = Newtonsoft.Json.JsonConvert.DeserializeObject(msg);
string type = json.type;
Console.WriteLine( $"HandleMessage:{type}" );
if (type == "GET_CONFIG")
{
// Simulate Delay for Loading Screen Test
@@ -131,6 +132,20 @@ namespace HMIWeb
var response = new { type = "CONFIG_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(configJson) };
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
}
else if (type == "GET_IO_LIST")
{
var bridge = new MachineBridge(_mainForm);
string ioJson = bridge.GetIOList();
var response = new { type = "IO_LIST_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(ioJson) };
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
}
else if (type == "GET_RECIPE_LIST")
{
var bridge = new MachineBridge(_mainForm);
string recipeJson = bridge.GetRecipeList();
var response = new { type = "RECIPE_LIST_DATA", data = Newtonsoft.Json.JsonConvert.DeserializeObject(recipeJson) };
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
}
else if (type == "SAVE_CONFIG")
{
string configJson = Newtonsoft.Json.JsonConvert.SerializeObject(json.data);
@@ -154,6 +169,14 @@ namespace HMIWeb
bool state = json.state;
_mainForm.Invoke(new Action(() => _mainForm.SetOutput(id, state)));
}
else if (type == "SELECT_RECIPE")
{
string recipeId = json.recipeId;
var bridge = new MachineBridge(_mainForm);
string resultJson = bridge.SelectRecipe(recipeId);
var response = new { type = "RECIPE_SELECTED", data = Newtonsoft.Json.JsonConvert.DeserializeObject(resultJson) };
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
}
}
catch (Exception ex)
{

View File

@@ -1,26 +1,13 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Activity, Settings, Move, Camera, Play, Square, RotateCw,
Cpu, AlertTriangle, Siren, Terminal, Layers
} from 'lucide-react';
import { Machine3D } from './components/Machine3D';
import { SettingsModal } from './components/SettingsModal';
import { RecipePanel } from './components/RecipePanel';
import { IOPanel } from './components/IOPanel';
import { MotionPanel } from './components/MotionPanel';
import { CameraPanel } from './components/CameraPanel';
import { CyberPanel } from './components/common/CyberPanel';
import { TechButton } from './components/common/TechButton';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { Layout } from './components/layout/Layout';
import { HomePage } from './pages/HomePage';
import { IOMonitorPage } from './pages/IOMonitorPage';
import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from './types';
import { comms } from './communication';
// --- MOCK DATA ---
const MOCK_RECIPES: Recipe[] = [
{ id: '1', name: 'Wafer_Proc_300_Au', lastModified: '2023-10-25' },
{ id: '2', name: 'Wafer_Insp_200_Adv', lastModified: '2023-10-26' },
{ id: '3', name: 'Glass_Gen5_Bonding', lastModified: '2023-10-27' },
];
const INITIAL_IO: IOPoint[] = [
...Array.from({ length: 32 }, (_, i) => {
@@ -47,34 +34,35 @@ const INITIAL_IO: IOPoint[] = [
// --- MAIN APP ---
export default function App() {
const [activeTab, setActiveTab] = useState<'recipe' | 'io' | 'motion' | 'camera' | 'setting' | null>(null);
const [activeTab, setActiveTab] = useState<'recipe' | 'motion' | 'camera' | 'setting' | null>(null);
const [activeIOTab, setActiveIOTab] = useState<'in' | 'out'>('in');
const [systemState, setSystemState] = useState<SystemState>(SystemState.IDLE);
const [currentRecipe, setCurrentRecipe] = useState<Recipe>(MOCK_RECIPES[0]);
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [currentRecipe, setCurrentRecipe] = useState<Recipe | null>(null);
const [robotTarget, setRobotTarget] = useState<RobotTarget>({ x: 0, y: 0, z: 0 });
const [logs, setLogs] = useState<LogEntry[]>([]);
const [ioPoints, setIoPoints] = useState<IOPoint[]>(INITIAL_IO);
const [ioPoints, setIoPoints] = useState<IOPoint[]>([]);
const [currentTime, setCurrentTime] = useState(new Date());
const [config, setConfig] = useState<ConfigItem[] | null>(null);
const [isConfigRefreshing, setIsConfigRefreshing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const videoRef = useRef<any>(null);
// Check if running in WebView2 context
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
const [isHostConnected, setIsHostConnected] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
// -- COMMUNICATION LAYER --
useEffect(() => {
// Subscribe to unified communication layer
const unsubscribe = comms.subscribe((msg: any) => {
if (!msg) return;
if (msg.type === 'CONNECTION_STATE') {
setIsHostConnected(msg.connected);
addLog(msg.connected ? "HOST CONNECTED" : "HOST DISCONNECTED", msg.connected ? "info" : "warning");
}
if (msg.type === 'STATUS_UPDATE') {
// Update Motion State
if (msg.position) {
setRobotTarget({ x: msg.position.x, y: msg.position.y, z: msg.position.z });
}
// Update IO State (Merge with existing names/configs)
if (msg.ioState) {
setIoPoints(prev => {
const newIO = [...prev];
@@ -85,7 +73,6 @@ export default function App() {
return newIO;
});
}
// Update System State
if (msg.sysState) {
setSystemState(msg.sysState as SystemState);
}
@@ -93,8 +80,8 @@ export default function App() {
});
addLog("COMMUNICATION CHANNEL OPEN", "info");
setIsHostConnected(comms.getConnectionState());
// Timer for Clock
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => {
clearInterval(timer);
@@ -105,30 +92,39 @@ export default function App() {
// -- INITIALIZATION --
useEffect(() => {
const initSystem = async () => {
// Just start up without fetching config
addLog("SYSTEM STARTED", "info");
try {
const ioStr = await comms.getIOList();
const ioData = JSON.parse(ioStr);
setIoPoints(ioData);
addLog("IO LIST LOADED", "info");
const recipeStr = await comms.getRecipeList();
const recipeData = JSON.parse(recipeStr);
setRecipes(recipeData);
if (recipeData.length > 0) setCurrentRecipe(recipeData[0]);
addLog("RECIPE LIST LOADED", "info");
} catch (e) {
addLog("FAILED TO LOAD SYSTEM DATA", "error");
// Fallback to empty or keep initial if needed
}
setIsLoading(false);
};
initSystem();
}, []);
// -- ON-DEMAND CONFIG FETCH --
useEffect(() => {
if (activeTab === 'setting') {
const fetchConfig = async () => {
setIsConfigRefreshing(true);
try {
const configStr = await comms.getConfig();
setConfig(JSON.parse(configStr));
addLog("CONFIG REFRESHED", "info");
} catch (e) {
addLog("CONFIG REFRESH FAILED", "error");
}
setIsConfigRefreshing(false);
};
fetchConfig();
// -- CONFIG FETCHING (for settings modal) --
const fetchConfig = React.useCallback(async () => {
setIsConfigRefreshing(true);
try {
const configStr = await comms.getConfig();
setConfig(JSON.parse(configStr));
addLog("CONFIG REFRESHED", "info");
} catch (e) {
addLog("CONFIG REFRESH FAILED", "error");
}
}, [activeTab]);
setIsConfigRefreshing(false);
}, []);
const addLog = (msg: string, type: 'info' | 'warning' | 'error' = 'info') => {
setLogs(prev => [{ id: Date.now() + Math.random(), timestamp: new Date().toLocaleTimeString(), message: msg, type }, ...prev].slice(0, 50));
@@ -146,8 +142,6 @@ export default function App() {
// -- COMMAND HANDLERS --
// -- COMMAND HANDLERS --
const handleControl = async (action: 'start' | 'stop' | 'reset') => {
if (isEmergencyStop && action === 'start') return addLog('EMERGENCY STOP ACTIVE', 'error');
@@ -160,7 +154,6 @@ export default function App() {
};
const toggleIO = async (id: number, type: 'input' | 'output', forceState?: boolean) => {
// Only allow output toggling
if (type === 'output') {
const current = ioPoints.find(p => p.id === id && p.type === type)?.state;
const nextState = forceState !== undefined ? forceState : !current;
@@ -174,215 +167,87 @@ export default function App() {
addLog(`CMD MOVE ${axis}: ${value}`, 'info');
};
useEffect(() => {
if (activeTab === 'camera' && navigator.mediaDevices?.getUserMedia) {
navigator.mediaDevices.getUserMedia({ video: true }).then(s => { if (videoRef.current) videoRef.current.srcObject = s });
}
}, [activeTab]);
const handleSaveConfig = async (newConfig: ConfigItem[]) => {
try {
await comms.saveConfig(JSON.stringify(newConfig));
setConfig(newConfig);
addLog("CONFIGURATION SAVED", "success");
addLog("CONFIGURATION SAVED", "info");
} catch (e) {
console.error(e);
addLog("FAILED TO SAVE CONFIG", "error");
}
};
const handleSelectRecipe = async (r: Recipe) => {
try {
addLog(`LOADING: ${r.name}`, 'info');
const result = await comms.selectRecipe(r.id);
if (result.success) {
setCurrentRecipe(r);
addLog(`RECIPE LOADED: ${r.name}`, 'info');
} else {
addLog(`RECIPE LOAD FAILED: ${result.message}`, 'error');
}
} catch (error: any) {
addLog(`RECIPE LOAD ERROR: ${error.message || 'Unknown error'}`, 'error');
console.error('Recipe selection error:', error);
}
};
return (
<div className="relative w-screen h-screen bg-slate-950 text-slate-100 overflow-hidden font-sans">
{/* Animated Nebula Background */}
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-[#050a15] to-[#0a0f20] animate-gradient bg-[length:400%_400%] z-0"></div>
<div className="absolute inset-0 grid-bg opacity-30 z-0"></div>
<div className="absolute inset-0 scanlines z-50 pointer-events-none"></div>
{/* LOADING OVERLAY */}
{isLoading && (
<div className="absolute inset-0 z-[100] bg-black flex flex-col items-center justify-center gap-6">
<div className="relative">
<div className="w-24 h-24 border-4 border-neon-blue/30 rounded-full animate-spin"></div>
<div className="absolute inset-0 border-t-4 border-neon-blue rounded-full animate-spin"></div>
<Cpu className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-neon-blue w-10 h-10 animate-pulse" />
</div>
<div className="text-center">
<h2 className="text-2xl font-tech font-bold text-white tracking-widest mb-2">SYSTEM INITIALIZING</h2>
<p className="font-mono text-neon-blue text-sm animate-pulse">ESTABLISHING CONNECTION...</p>
</div>
</div>
)}
{/* HEADER */}
<header className="absolute top-0 left-0 right-0 h-20 px-6 flex items-center justify-between z-40 bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
<div className="flex items-center gap-4 pointer-events-auto">
<div className="w-12 h-12 border-2 border-neon-blue flex items-center justify-center rounded shadow-glow-blue bg-black/50 backdrop-blur">
<Cpu className="text-neon-blue w-8 h-8 animate-pulse-slow" />
</div>
<div>
<h1 className="text-3xl font-tech font-bold text-white tracking-widest uppercase italic text-shadow-glow-blue">
EQUI-HANDLER <span className="text-neon-blue text-sm not-italic">PRO</span>
</h1>
<div className="flex gap-2 text-[10px] text-neon-blue/70 font-mono">
<span>SYS.VER 4.2.0</span>
<span>|</span>
<span className={isWebView ? "text-neon-green" : "text-amber-500"}>
LINK: {isWebView ? "NATIVE" : "SIMULATION"}
</span>
</div>
</div>
</div>
{/* Top Navigation */}
<div className="flex items-center gap-2 pointer-events-auto bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10">
{[
{ id: 'recipe', icon: Layers, label: 'RECIPE' },
{ id: 'io', icon: Activity, label: 'I/O MONITOR' },
{ id: 'motion', icon: Move, label: 'MOTION' },
{ id: 'camera', icon: Camera, label: 'VISION' },
{ id: 'setting', icon: Settings, label: 'CONFIG' }
].map(item => (
<button
key={item.id}
onClick={() => setActiveTab(activeTab === item.id ? null : item.id as any)}
className={`
flex items-center gap-2 px-6 py-2 rounded-full font-tech font-bold text-sm transition-all border border-transparent
${activeTab === item.id
? 'bg-neon-blue/10 text-neon-blue border-neon-blue shadow-glow-blue'
: 'text-slate-400 hover:text-white hover:bg-white/5'}
`}
>
<item.icon className="w-4 h-4" /> {item.label}
</button>
))}
</div>
<div className="text-right pointer-events-auto">
<div className="text-2xl font-mono font-bold text-white text-shadow-glow-blue">
{currentTime.toLocaleTimeString('en-GB')}
</div>
<div className="text-xs font-tech text-slate-400 tracking-[0.3em]">
{currentTime.toLocaleDateString().toUpperCase()}
</div>
</div>
</header>
{/* MAIN CONTENT */}
<main className="absolute inset-0 pt-24 pb-12 px-6 flex gap-6 z-10">
{/* 3D Canvas (Background Layer) */}
<div className="absolute inset-0 z-0">
<Machine3D target={robotTarget} ioState={ioPoints} doorStates={doorStates} />
</div>
{/* Center Alarms */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none flex flex-col items-center gap-4">
{isEmergencyStop && (
<div className="bg-red-600/90 text-white p-8 border-4 border-red-500 shadow-glow-red flex items-center gap-6 animate-pulse">
<Siren className="w-16 h-16 animate-spin" />
<div>
<h1 className="text-5xl font-tech font-bold tracking-widest">EMERGENCY STOP</h1>
<p className="text-center font-mono text-lg">SYSTEM HALTED - RELEASE TO RESET</p>
</div>
</div>
)}
{isLowPressure && !isEmergencyStop && (
<div className="bg-amber-500/80 text-black px-8 py-4 rounded font-bold text-2xl tracking-widest flex items-center gap-4 shadow-glow-red animate-bounce">
<AlertTriangle className="w-8 h-8" /> LOW AIR PRESSURE WARNING
</div>
)}
</div>
{/* Floating Panel (Left) */}
{activeTab && activeTab !== 'setting' && (
<div className="w-[450px] z-20 animate-in slide-in-from-left-20 duration-500 fade-in">
<CyberPanel className="h-full flex flex-col">
{activeTab === 'recipe' && <RecipePanel recipes={MOCK_RECIPES} currentRecipe={currentRecipe} onSelectRecipe={(r) => { setCurrentRecipe(r); addLog(`LOADED: ${r.name}`) }} />}
{activeTab === 'io' && <IOPanel ioPoints={ioPoints} onToggle={toggleIO} />}
{activeTab === 'motion' && <MotionPanel robotTarget={robotTarget} onMove={moveAxis} />}
{activeTab === 'camera' && <CameraPanel videoRef={videoRef} />}
</CyberPanel>
</div>
)}
{/* Settings Modal */}
<SettingsModal
isOpen={activeTab === 'setting'}
onClose={() => setActiveTab(null)}
config={config}
isRefreshing={isConfigRefreshing}
onSave={handleSaveConfig}
/>
{/* Right Sidebar (Dashboard) */}
<div className="w-80 ml-auto z-20 flex flex-col gap-4">
<CyberPanel className="flex-none">
<div className="mb-2 text-xs text-neon-blue font-bold tracking-widest uppercase">System Status</div>
<div className={`text-3xl font-tech font-bold mb-4 ${systemState === SystemState.RUNNING ? 'text-neon-green text-shadow-glow-green' : 'text-slate-400'}`}>
{systemState}
</div>
<div className="space-y-3">
<TechButton
variant="green"
className="w-full flex items-center justify-center gap-2"
active={systemState === SystemState.RUNNING}
onClick={() => handleControl('start')}
>
<Play className="w-4 h-4" /> START AUTO
</TechButton>
<TechButton
variant="amber"
className="w-full flex items-center justify-center gap-2"
active={systemState === SystemState.PAUSED}
onClick={() => handleControl('stop')}
>
<Square className="w-4 h-4 fill-current" /> STOP / PAUSE
</TechButton>
<TechButton
className="w-full flex items-center justify-center gap-2"
onClick={() => handleControl('reset')}
>
<RotateCw className="w-4 h-4" /> SYSTEM RESET
</TechButton>
</div>
</CyberPanel>
<CyberPanel className="flex-1 flex flex-col min-h-0">
<div className="mb-2 flex items-center justify-between text-xs text-neon-blue font-bold tracking-widest uppercase border-b border-white/10 pb-2">
<span>Event Log</span>
<Terminal className="w-3 h-3" />
</div>
<div className="flex-1 overflow-y-auto font-mono text-[10px] space-y-1 pr-1 custom-scrollbar">
{logs.map(log => (
<div key={log.id} className={`flex gap-2 ${log.type === 'error' ? 'text-red-500' : log.type === 'warning' ? 'text-amber-400' : 'text-slate-400'}`}>
<span className="opacity-50">[{log.timestamp}]</span>
<span>{log.message}</span>
</div>
))}
</div>
</CyberPanel>
</div>
</main>
{/* FOOTER STATUS */}
<footer className="absolute bottom-0 left-0 right-0 h-10 bg-black/80 border-t border-neon-blue/30 flex items-center px-6 justify-between z-40 backdrop-blur text-xs font-mono text-slate-400">
<div className="flex gap-6">
{['PLC', 'MOTION', 'VISION', 'LIGHT'].map(hw => (
<div key={hw} className="flex items-center gap-2">
<div className="w-2 h-2 bg-neon-green rounded-full shadow-[0_0_5px_#0aff00]"></div>
<span className="font-bold text-slate-300">{hw}</span>
</div>
))}
</div>
<div className="flex gap-8 text-neon-blue">
<span>POS.X: {robotTarget.x.toFixed(3)}</span>
<span>POS.Y: {robotTarget.y.toFixed(3)}</span>
<span>POS.Z: {robotTarget.z.toFixed(3)}</span>
</div>
</footer>
</div>
<HashRouter>
<Layout
currentTime={currentTime}
isHostConnected={isHostConnected}
robotTarget={robotTarget}
onTabChange={(tab) => {
setActiveTab(tab);
if (tab === null) setActiveIOTab('in'); // Reset IO tab when closing
}}
activeTab={activeTab}
isLoading={isLoading}
>
<Routes>
<Route
path="/"
element={
<HomePage
systemState={systemState}
currentRecipe={currentRecipe || { id: '0', name: 'No Recipe', lastModified: '-' }}
recipes={recipes}
robotTarget={robotTarget}
logs={logs}
ioPoints={ioPoints}
config={config}
isConfigRefreshing={isConfigRefreshing}
doorStates={doorStates}
isLowPressure={isLowPressure}
isEmergencyStop={isEmergencyStop}
activeTab={activeTab}
onSelectRecipe={handleSelectRecipe}
onMove={moveAxis}
onControl={handleControl}
onSaveConfig={handleSaveConfig}
onFetchConfig={fetchConfig}
onCloseTab={() => setActiveTab(null)}
videoRef={videoRef}
/>
}
/>
<Route
path="/io-monitor"
element={
<IOMonitorPage
ioPoints={ioPoints}
onToggle={toggleIO}
activeIOTab={activeIOTab}
onIOTabChange={setActiveIOTab}
/>
}
/>
</Routes>
</Layout>
</HashRouter>
);
}

View File

@@ -12,6 +12,7 @@ class CommunicationLayer {
constructor() {
if (isWebView) {
console.log("[COMM] Running in WebView2 Mode");
this.isConnected = true; // WebView2 is always connected
window.chrome.webview.addEventListener('message', (event: any) => {
this.notifyListeners(event.data);
});
@@ -27,6 +28,7 @@ class CommunicationLayer {
this.ws.onopen = () => {
console.log("[COMM] WebSocket Connected");
this.isConnected = true;
this.notifyListeners({ type: 'CONNECTION_STATE', connected: true });
};
this.ws.onmessage = (event) => {
@@ -41,6 +43,7 @@ class CommunicationLayer {
this.ws.onclose = () => {
console.log("[COMM] WebSocket Closed. Reconnecting...");
this.isConnected = false;
this.notifyListeners({ type: 'CONNECTION_STATE', connected: false });
setTimeout(() => this.connectWebSocket(), 2000);
};
@@ -60,6 +63,10 @@ class CommunicationLayer {
};
}
public getConnectionState(): boolean {
return this.isConnected;
}
// --- API Methods ---
public async getConfig(): Promise<string> {
@@ -95,6 +102,64 @@ class CommunicationLayer {
}
}
public async getIOList(): Promise<string> {
if (isWebView) {
return await window.chrome.webview.hostObjects.machine.GetIOList();
} else {
return new Promise((resolve, reject) => {
if (!this.isConnected) {
setTimeout(() => {
if (!this.isConnected) reject("WebSocket connection timeout");
}, 2000);
}
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject("IO fetch timeout");
}, 10000);
const handler = (data: any) => {
if (data.type === 'IO_LIST_DATA') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(JSON.stringify(data.data));
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'GET_IO_LIST' }));
});
}
}
public async getRecipeList(): Promise<string> {
if (isWebView) {
return await window.chrome.webview.hostObjects.machine.GetRecipeList();
} else {
return new Promise((resolve, reject) => {
if (!this.isConnected) {
setTimeout(() => {
if (!this.isConnected) reject("WebSocket connection timeout");
}, 2000);
}
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject("Recipe fetch timeout");
}, 10000);
const handler = (data: any) => {
if (data.type === 'RECIPE_LIST_DATA') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(JSON.stringify(data.data));
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'GET_RECIPE_LIST' }));
});
}
}
public async saveConfig(configJson: string): Promise<void> {
if (isWebView) {
await window.chrome.webview.hostObjects.machine.SaveConfig(configJson);
@@ -126,6 +191,36 @@ class CommunicationLayer {
this.ws?.send(JSON.stringify({ type: 'SET_IO', id, state }));
}
}
public async selectRecipe(recipeId: string): Promise<{ success: boolean; message: string; recipeId?: string }> {
if (isWebView) {
const resultJson = await window.chrome.webview.hostObjects.machine.SelectRecipe(recipeId);
return JSON.parse(resultJson);
} else {
return new Promise((resolve, reject) => {
if (!this.isConnected) {
setTimeout(() => {
if (!this.isConnected) reject({ success: false, message: "WebSocket connection timeout" });
}, 2000);
}
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject({ success: false, message: "Recipe selection timeout" });
}, 10000);
const handler = (data: any) => {
if (data.type === 'RECIPE_SELECTED') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'SELECT_RECIPE', recipeId }));
});
}
}
}
export const comms = new CommunicationLayer();

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Box, Cpu, Activity } from 'lucide-react';
import { Recipe } from '../types';
import { CyberPanel } from './common/CyberPanel';
interface ModelInfoPanelProps {
currentRecipe: Recipe;
}
export const ModelInfoPanel: React.FC<ModelInfoPanelProps> = ({ currentRecipe }) => {
return (
<CyberPanel className="flex-none">
<div className="mb-3 flex items-center justify-between text-xs text-neon-blue font-bold tracking-widest uppercase border-b border-white/10 pb-2">
<span>Model Information</span>
<Box className="w-3 h-3" />
</div>
<div className="space-y-4">
<div>
<div className="text-[10px] text-slate-500 font-mono mb-1">SELECTED MODEL</div>
<div className="text-xl font-bold text-white tracking-wide truncate flex items-center gap-2">
<Cpu className="w-4 h-4 text-neon-blue" />
{currentRecipe.name}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="bg-white/5 rounded p-2 border border-white/5">
<div className="text-[9px] text-slate-500 font-mono uppercase">Model ID</div>
<div className="text-sm font-mono text-neon-blue">{currentRecipe.id}</div>
</div>
<div className="bg-white/5 rounded p-2 border border-white/5">
<div className="text-[9px] text-slate-500 font-mono uppercase">Last Mod</div>
<div className="text-sm font-mono text-slate-300">{currentRecipe.lastModified}</div>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between text-[10px] font-mono text-slate-400">
<span>TARGET CYCLE</span>
<span className="text-neon-green">12.5s</span>
</div>
<div className="w-full h-1 bg-slate-800 rounded-full overflow-hidden">
<div className="h-full w-[85%] bg-neon-green/50"></div>
</div>
<div className="flex justify-between text-[10px] font-mono text-slate-400 mt-2">
<span>EST. YIELD</span>
<span className="text-neon-blue">99.8%</span>
</div>
<div className="w-full h-1 bg-slate-800 rounded-full overflow-hidden">
<div className="h-full w-[99%] bg-neon-blue/50"></div>
</div>
</div>
</div>
</CyberPanel>
);
};

View File

@@ -1,40 +1,84 @@
import React from 'react';
import { FileText, CheckCircle2, Layers } from 'lucide-react';
import React, { useState } from 'react';
import { Layers, Check, Settings, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Recipe } from '../types';
import { PanelHeader } from './common/PanelHeader';
import { TechButton } from './common/TechButton';
interface RecipePanelProps {
recipes: Recipe[];
currentRecipe: Recipe;
onSelectRecipe: (r: Recipe) => void;
onClose: () => void;
}
export const RecipePanel: React.FC<RecipePanelProps> = ({ recipes, currentRecipe, onSelectRecipe }) => (
<div className="h-full flex flex-col">
<PanelHeader title="Recipe Select" icon={FileText} />
<div className="space-y-2 flex-1 overflow-y-auto custom-scrollbar pr-2">
{recipes.map(r => (
<div
key={r.id}
onClick={() => onSelectRecipe(r)}
className={`
p-3 cursor-pointer flex justify-between items-center transition-all border-l-4
${currentRecipe.id === r.id
? 'bg-white/10 border-neon-blue text-white shadow-[inset_0_0_20px_rgba(0,243,255,0.1)]'
: 'bg-black/20 border-transparent text-slate-500 hover:bg-white/5 hover:text-slate-300'}
`}
>
<div>
<div className="font-tech font-bold text-sm tracking-wide">{r.name}</div>
<div className="text-[10px] font-mono opacity-60">{r.lastModified}</div>
export const RecipePanel: React.FC<RecipePanelProps> = ({ recipes, currentRecipe, onSelectRecipe, onClose }) => {
const navigate = useNavigate();
const [selectedId, setSelectedId] = useState<string>(currentRecipe.id);
const handleConfirm = () => {
const selected = recipes.find(r => r.id === selectedId);
if (selected) {
onSelectRecipe(selected);
onClose();
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-[500px] bg-slate-950/90 border border-neon-blue/30 rounded-lg shadow-2xl flex flex-col overflow-hidden">
{/* Header */}
<div className="h-12 bg-white/5 flex items-center justify-between px-4 border-b border-white/10">
<div className="flex items-center gap-2 text-neon-blue font-tech font-bold tracking-wider">
<Layers className="w-4 h-4" /> RECIPE SELECTION
</div>
{currentRecipe.id === r.id && <CheckCircle2 className="w-4 h-4 text-neon-blue" />}
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
))}
{/* List (Max height for ~5 items) */}
<div className="p-4 max-h-[320px] overflow-y-auto custom-scrollbar">
<div className="space-y-2">
{recipes.map(recipe => (
<div
key={recipe.id}
onClick={() => setSelectedId(recipe.id)}
className={`
p-3 rounded border cursor-pointer transition-all flex items-center justify-between
${selectedId === recipe.id
? 'bg-neon-blue/20 border-neon-blue text-white shadow-[0_0_10px_rgba(0,243,255,0.2)]'
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20'}
`}
>
<div>
<div className="font-bold tracking-wide">{recipe.name}</div>
<div className="text-[10px] font-mono opacity-70">ID: {recipe.id} | MOD: {recipe.lastModified}</div>
</div>
{selectedId === recipe.id && <Check className="w-4 h-4 text-neon-blue" />}
</div>
))}
</div>
</div>
{/* Footer Actions */}
<div className="p-4 border-t border-white/10 flex justify-between gap-4 bg-black/20">
<TechButton
variant="default"
className="flex items-center gap-2 px-4"
onClick={() => navigate('/recipe')}
>
<Settings className="w-4 h-4" /> MANAGEMENT
</TechButton>
<TechButton
variant="blue"
className="flex items-center gap-2 px-8"
onClick={handleConfirm}
>
SELECT
</TechButton>
</div>
</div>
</div>
<TechButton className="w-full mt-4 flex items-center justify-center gap-2">
<Layers className="w-4 h-4" /> New Recipe
</TechButton>
</div>
);
);
};

View File

@@ -4,8 +4,10 @@ interface TechButtonProps {
children?: React.ReactNode;
onClick?: () => void;
active?: boolean;
variant?: 'blue' | 'red' | 'amber' | 'green';
variant?: 'blue' | 'red' | 'amber' | 'green' | 'default' | 'danger';
className?: string;
disabled?: boolean;
title?: string;
}
export const TechButton: React.FC<TechButtonProps> = ({
@@ -13,26 +15,39 @@ export const TechButton: React.FC<TechButtonProps> = ({
onClick,
active = false,
variant = 'blue',
className = ''
className = '',
disabled = false,
title
}) => {
const colors = {
blue: 'from-blue-600 to-cyan-600 hover:shadow-glow-blue border-cyan-400/30',
red: 'from-red-600 to-pink-600 hover:shadow-glow-red border-red-400/30',
amber: 'from-amber-500 to-orange-600 hover:shadow-orange-500/50 border-orange-400/30',
green: 'from-emerald-500 to-green-600 hover:shadow-green-500/50 border-green-400/30'
green: 'from-emerald-500 to-green-600 hover:shadow-green-500/50 border-green-400/30',
default: 'from-slate-600 to-slate-500 hover:shadow-slate-500/50 border-slate-400/30',
danger: 'from-red-600 to-pink-600 hover:shadow-glow-red border-red-400/30'
};
const variantKey = variant === 'danger' ? 'red' : (variant === 'default' ? 'blue' : variant);
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`
relative px-4 py-2 font-tech font-bold tracking-wider uppercase transition-all duration-300
clip-tech border-b-2 border-r-2
${active ? `bg-gradient-to-r ${colors[variant]} text-white` : 'bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-700/50 border-slate-600'}
${disabled
? 'opacity-50 cursor-not-allowed bg-slate-900 text-slate-600 border-slate-800'
: (active
? `bg-gradient-to-r ${colors[variantKey]} text-white`
: 'bg-slate-800/50 text-slate-400 hover:text-white hover:bg-slate-700/50 border-slate-600')
}
${className}
`}
>
{active && <div className="absolute inset-0 bg-white/20 animate-pulse pointer-events-none"></div>}
{active && !disabled && <div className="absolute inset-0 bg-white/20 animate-pulse pointer-events-none"></div>}
{children}
</button>
);

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { RobotTarget } from '../../types';
interface FooterProps {
isHostConnected: boolean;
robotTarget: RobotTarget;
}
export const Footer: React.FC<FooterProps> = ({ isHostConnected, robotTarget }) => {
return (
<footer className="absolute bottom-0 left-0 right-0 h-10 bg-black/80 border-t border-neon-blue/30 flex items-center px-6 justify-between z-40 backdrop-blur text-xs font-mono text-slate-400">
<div className="flex gap-6">
{['PLC', 'MOTION', 'VISION', 'LIGHT'].map(hw => (
<div key={hw} className="flex items-center gap-2">
<div className="w-2 h-2 bg-neon-green rounded-full shadow-[0_0_5px_#0aff00]"></div>
<span className="font-bold text-slate-300">{hw}</span>
</div>
))}
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full transition-all ${isHostConnected ? 'bg-neon-green shadow-[0_0_5px_#0aff00]' : 'bg-red-500 shadow-[0_0_5px_#ff0000] animate-pulse'}`}></div>
<span className={`font-bold ${isHostConnected ? 'text-slate-300' : 'text-red-400'}`}>HOST</span>
</div>
</div>
<div className="flex gap-8 text-neon-blue">
<span>POS.X: {robotTarget.x.toFixed(3)}</span>
<span>POS.Y: {robotTarget.y.toFixed(3)}</span>
<span>POS.Z: {robotTarget.z.toFixed(3)}</span>
</div>
</footer>
);
};

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Activity, Settings, Move, Camera, Layers, Cpu } from 'lucide-react';
interface HeaderProps {
currentTime: Date;
onTabChange: (tab: 'recipe' | 'motion' | 'camera' | 'setting' | null) => void;
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null;
}
export const Header: React.FC<HeaderProps> = ({ currentTime, onTabChange, activeTab }) => {
const navigate = useNavigate();
const location = useLocation();
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
const isIOPage = location.pathname === '/io-monitor';
return (
<header className="absolute top-0 left-0 right-0 h-20 px-6 flex items-center justify-between z-40 bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
<div
className="flex items-center gap-4 pointer-events-auto cursor-pointer group"
onClick={() => {
navigate('/');
onTabChange(null);
}}
>
<div className="w-12 h-12 border-2 border-neon-blue flex items-center justify-center rounded shadow-glow-blue bg-black/50 backdrop-blur group-hover:bg-neon-blue/10 transition-colors">
<Cpu className="text-neon-blue w-8 h-8 animate-pulse-slow" />
</div>
<div>
<h1 className="text-3xl font-tech font-bold text-white tracking-widest uppercase italic text-shadow-glow-blue group-hover:text-neon-blue transition-colors">
EQUI-HANDLER <span className="text-neon-blue text-sm not-italic">PRO</span>
</h1>
<div className="flex gap-2 text-[10px] text-neon-blue/70 font-mono">
<span>SYS.VER 4.2.0</span>
<span>|</span>
<span className={isWebView ? "text-neon-green" : "text-amber-500"}>
LINK: {isWebView ? "NATIVE" : "SIMULATION"}
</span>
</div>
</div>
</div>
{/* Top Navigation */}
<div className="flex items-center gap-2 pointer-events-auto">
{/* IO Tab Switcher (only on IO page) */}
<div className="bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10 flex gap-1">
{[
{ id: 'recipe', icon: Layers, label: 'RECIPE', path: '/' },
{ id: 'io', icon: Activity, label: 'I/O MONITOR', path: '/io-monitor' },
{ id: 'motion', icon: Move, label: 'MOTION', path: '/' },
{ id: 'camera', icon: Camera, label: 'VISION', path: '/' },
{ id: 'setting', icon: Settings, label: 'CONFIG', path: '/' }
].map(item => {
const isActive = item.id === 'io'
? location.pathname === '/io-monitor'
: activeTab === item.id;
return (
<button
key={item.id}
onClick={() => {
if (item.id === 'io') {
navigate('/io-monitor');
onTabChange(null);
} else {
if (location.pathname !== '/') {
navigate('/');
}
onTabChange(activeTab === item.id ? null : item.id as any);
}
}}
className={`
flex items-center gap-2 px-6 py-2 rounded-full font-tech font-bold text-sm transition-all border border-transparent
${isActive
? 'bg-neon-blue/10 text-neon-blue border-neon-blue shadow-glow-blue'
: 'text-slate-400 hover:text-white hover:bg-white/5'}
`}
>
<item.icon className="w-4 h-4" /> {item.label}
</button>
);
})}
</div>
</div>
<div className="text-right pointer-events-auto">
<div className="text-2xl font-mono font-bold text-white text-shadow-glow-blue">
{currentTime.toLocaleTimeString('en-GB')}
</div>
<div className="text-xs font-tech text-slate-400 tracking-[0.3em]">
{currentTime.toLocaleDateString().toUpperCase()}
</div>
</div>
</header>
);
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Header } from './Header';
import { Footer } from './Footer';
import { RobotTarget } from '../../types';
interface LayoutProps {
children: React.ReactNode;
currentTime: Date;
isHostConnected: boolean;
robotTarget: RobotTarget;
onTabChange: (tab: 'recipe' | 'motion' | 'camera' | 'setting' | null) => void;
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null;
isLoading: boolean;
}
export const Layout: React.FC<LayoutProps> = ({
children,
currentTime,
isHostConnected,
robotTarget,
onTabChange,
activeTab,
isLoading
}) => {
return (
<div className="relative w-screen h-screen bg-slate-950 text-slate-100 overflow-hidden font-sans">
{/* Animated Nebula Background */}
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-[#050a15] to-[#0a0f20] animate-gradient bg-[length:400%_400%] z-0"></div>
<div className="absolute inset-0 grid-bg opacity-30 z-0"></div>
<div className="absolute inset-0 scanlines z-50 pointer-events-none"></div>
{/* LOADING OVERLAY */}
{isLoading && (
<div className="absolute inset-0 z-[100] bg-black flex flex-col items-center justify-center gap-6">
<div className="relative">
<div className="w-24 h-24 border-4 border-neon-blue/30 rounded-full animate-spin"></div>
<div className="absolute inset-0 border-t-4 border-neon-blue rounded-full animate-spin"></div>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-neon-blue text-2xl font-tech font-bold"></div>
</div>
<div className="text-center">
<h2 className="text-2xl font-tech font-bold text-white tracking-widest mb-2">SYSTEM INITIALIZING</h2>
<p className="font-mono text-neon-blue text-sm animate-pulse">ESTABLISHING CONNECTION...</p>
</div>
</div>
)}
<Header
currentTime={currentTime}
onTabChange={onTabChange}
activeTab={activeTab}
/>
{/* Main Content Area */}
<div className="absolute inset-0 pt-20 pb-10 z-10">
{children}
</div>
<Footer isHostConnected={isHostConnected} robotTarget={robotTarget} />
</div>
);
};

View File

@@ -16,6 +16,7 @@
"lucide-react": "^0.303.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.9.6",
"tailwind-merge": "^2.2.0",
"three": "^0.160.0"
},
@@ -1800,6 +1801,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
@@ -2801,6 +2811,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
"integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
"integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.6"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-use-measure": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
@@ -2965,6 +3013,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -17,6 +17,7 @@
"lucide-react": "^0.303.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.9.6",
"tailwind-merge": "^2.2.0",
"three": "^0.160.0"
},

174
frontend/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,174 @@
import React, { useState, useEffect } from 'react';
import { Play, Square, RotateCw, AlertTriangle, Siren, Terminal } from 'lucide-react';
import { Machine3D } from '../components/Machine3D';
import { SettingsModal } from '../components/SettingsModal';
import { RecipePanel } from '../components/RecipePanel';
import { MotionPanel } from '../components/MotionPanel';
import { CameraPanel } from '../components/CameraPanel';
import { CyberPanel } from '../components/common/CyberPanel';
import { TechButton } from '../components/common/TechButton';
import { ModelInfoPanel } from '../components/ModelInfoPanel';
import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from '../types';
interface HomePageProps {
systemState: SystemState;
currentRecipe: Recipe;
recipes: Recipe[];
robotTarget: RobotTarget;
logs: LogEntry[];
ioPoints: IOPoint[];
config: ConfigItem[] | null;
isConfigRefreshing: boolean;
doorStates: { front: boolean; right: boolean; left: boolean; back: boolean };
isLowPressure: boolean;
isEmergencyStop: boolean;
activeTab: 'recipe' | 'motion' | 'camera' | 'setting' | null;
onSelectRecipe: (r: Recipe) => void;
onMove: (axis: 'X' | 'Y' | 'Z', val: number) => void;
onControl: (action: 'start' | 'stop' | 'reset') => void;
onSaveConfig: (config: ConfigItem[]) => void;
onFetchConfig: () => void;
onCloseTab: () => void;
videoRef: React.RefObject<HTMLVideoElement>;
}
export const HomePage: React.FC<HomePageProps> = ({
systemState,
currentRecipe,
recipes,
robotTarget,
logs,
ioPoints,
config,
isConfigRefreshing,
doorStates,
isLowPressure,
isEmergencyStop,
activeTab,
onSelectRecipe,
onMove,
onControl,
onSaveConfig,
onFetchConfig,
onCloseTab,
videoRef
}) => {
useEffect(() => {
if (activeTab === 'camera' && navigator.mediaDevices?.getUserMedia) {
navigator.mediaDevices.getUserMedia({ video: true }).then(s => { if (videoRef.current) videoRef.current.srcObject = s });
}
}, [activeTab, videoRef]);
useEffect(() => {
if (activeTab === 'setting') {
onFetchConfig();
}
}, [activeTab, onFetchConfig]);
return (
<main className="relative w-full h-full flex gap-6 px-6">
{/* 3D Canvas (Background Layer) */}
<div className="absolute inset-0 z-0">
<Machine3D target={robotTarget} ioState={ioPoints} doorStates={doorStates} />
</div>
{/* Center Alarms */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none flex flex-col items-center gap-4">
{isEmergencyStop && (
<div className="bg-red-600/90 text-white p-8 border-4 border-red-500 shadow-glow-red flex items-center gap-6 animate-pulse">
<Siren className="w-16 h-16 animate-spin" />
<div>
<h1 className="text-5xl font-tech font-bold tracking-widest">EMERGENCY STOP</h1>
<p className="text-center font-mono text-lg">SYSTEM HALTED - RELEASE TO RESET</p>
</div>
</div>
)}
{isLowPressure && !isEmergencyStop && (
<div className="bg-amber-500/80 text-black px-8 py-4 rounded font-bold text-2xl tracking-widest flex items-center gap-4 shadow-glow-red animate-bounce">
<AlertTriangle className="w-8 h-8" /> LOW AIR PRESSURE WARNING
</div>
)}
</div>
{/* Floating Panel (Left) */}
{activeTab && activeTab !== 'setting' && activeTab !== 'recipe' && (
<div className="w-[450px] z-20 animate-in slide-in-from-left-20 duration-500 fade-in">
<CyberPanel className="h-full flex flex-col">
{activeTab === 'motion' && <MotionPanel robotTarget={robotTarget} onMove={onMove} />}
{activeTab === 'camera' && <CameraPanel videoRef={videoRef} />}
</CyberPanel>
</div>
)}
{/* Recipe Selection Modal */}
{activeTab === 'recipe' && (
<RecipePanel
recipes={recipes}
currentRecipe={currentRecipe}
onSelectRecipe={onSelectRecipe}
onClose={onCloseTab}
/>
)}
{/* Settings Modal */}
<SettingsModal
isOpen={activeTab === 'setting'}
onClose={onCloseTab}
config={config}
isRefreshing={isConfigRefreshing}
onSave={onSaveConfig}
/>
{/* Right Sidebar (Dashboard) */}
<div className="w-80 ml-auto z-20 flex flex-col gap-4">
<ModelInfoPanel currentRecipe={currentRecipe} />
<CyberPanel className="flex-none">
<div className="mb-2 text-xs text-neon-blue font-bold tracking-widest uppercase">System Status</div>
<div className={`text-3xl font-tech font-bold mb-4 ${systemState === SystemState.RUNNING ? 'text-neon-green text-shadow-glow-green' : 'text-slate-400'}`}>
{systemState}
</div>
<div className="space-y-3">
<TechButton
variant="green"
className="w-full flex items-center justify-center gap-2"
active={systemState === SystemState.RUNNING}
onClick={() => onControl('start')}
>
<Play className="w-4 h-4" /> START AUTO
</TechButton>
<TechButton
variant="amber"
className="w-full flex items-center justify-center gap-2"
active={systemState === SystemState.PAUSED}
onClick={() => onControl('stop')}
>
<Square className="w-4 h-4 fill-current" /> STOP / PAUSE
</TechButton>
<TechButton
className="w-full flex items-center justify-center gap-2"
onClick={() => onControl('reset')}
>
<RotateCw className="w-4 h-4" /> SYSTEM RESET
</TechButton>
</div>
</CyberPanel>
<CyberPanel className="flex-1 flex flex-col min-h-0">
<div className="mb-2 flex items-center justify-between text-xs text-neon-blue font-bold tracking-widest uppercase border-b border-white/10 pb-2">
<span>Event Log</span>
<Terminal className="w-3 h-3" />
</div>
<div className="flex-1 overflow-y-auto font-mono text-[10px] space-y-1 pr-1 custom-scrollbar">
{logs.map(log => (
<div key={log.id} className={`flex gap-2 ${log.type === 'error' ? 'text-red-500' : log.type === 'warning' ? 'text-amber-400' : 'text-slate-400'}`}>
<span className="opacity-50">[{log.timestamp}]</span>
<span>{log.message}</span>
</div>
))}
</div>
</CyberPanel>
</div>
</main>
);
};

View File

@@ -0,0 +1,83 @@
import React, { useState } from 'react';
import { IOPoint } from '../types';
interface IOMonitorPageProps {
ioPoints: IOPoint[];
onToggle: (id: number, type: 'input' | 'output') => void;
activeIOTab: 'in' | 'out';
onIOTabChange: (tab: 'in' | 'out') => void;
}
export const IOMonitorPage: React.FC<IOMonitorPageProps> = ({ ioPoints, onToggle, activeIOTab, onIOTabChange }) => {
const points = ioPoints.filter(p => p.type === (activeIOTab === 'in' ? 'input' : 'output'));
return (
<main className="relative w-full h-full px-6 pt-6 pb-20">
<div className="glass-holo p-6 h-full flex flex-col gap-4">
{/* Local Header / Controls */}
<div className="flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<h2 className="text-2xl font-tech font-bold text-white tracking-wider">
SYSTEM I/O MONITOR
</h2>
<div className="h-6 w-px bg-white/20"></div>
<div className="text-sm font-mono text-neon-blue">
TOTAL POINTS: {ioPoints.length}
</div>
</div>
<div className="bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10 flex gap-1">
<button
onClick={() => onIOTabChange('in')}
className={`px-6 py-2 rounded-full font-tech font-bold text-sm transition-all ${activeIOTab === 'in' ? 'bg-neon-yellow/20 text-neon-yellow border border-neon-yellow shadow-[0_0_15px_rgba(255,230,0,0.3)]' : 'text-slate-400 hover:text-white hover:bg-white/5'}`}
>
INPUTS ({ioPoints.filter(p => p.type === 'input').length})
</button>
<button
onClick={() => onIOTabChange('out')}
className={`px-6 py-2 rounded-full font-tech font-bold text-sm transition-all ${activeIOTab === 'out' ? 'bg-neon-green/20 text-neon-green border border-neon-green shadow-[0_0_15px_rgba(10,255,0,0.3)]' : 'text-slate-400 hover:text-white hover:bg-white/5'}`}
>
OUTPUTS ({ioPoints.filter(p => p.type === 'output').length})
</button>
</div>
</div>
<div className="bg-slate-950/40 backdrop-blur-md flex-1 overflow-y-auto custom-scrollbar rounded-lg border border-white/5">
{/* Grid Layout - More columns for full screen */}
{/* Grid Layout - 2 Columns for list view */}
<div className="grid grid-cols-2 gap-x-8 gap-y-2 p-4">
{points.map(p => (
<div
key={p.id}
onClick={() => onToggle(p.id, p.type)}
className={`
flex items-center gap-4 px-4 py-3 cursor-pointer transition-all border
clip-tech-sm group hover:translate-x-1
${p.state
? (p.type === 'output'
? 'bg-neon-green/10 border-neon-green text-neon-green shadow-[0_0_15px_rgba(10,255,0,0.2)]'
: 'bg-neon-yellow/10 border-neon-yellow text-neon-yellow shadow-[0_0_15px_rgba(255,230,0,0.2)]')
: 'bg-slate-900/40 border-slate-800 text-slate-500 hover:border-slate-600 hover:bg-slate-800/40'}
`}
>
{/* LED Indicator */}
<div className={`w-3 h-3 rounded-full shrink-0 transition-all ${p.state ? (p.type === 'output' ? 'bg-neon-green shadow-[0_0_8px_#0aff00]' : 'bg-neon-yellow shadow-[0_0_8px_#ffe600]') : 'bg-slate-800 border border-slate-700'}`}></div>
{/* ID Badge */}
<div className={`w-12 font-mono font-bold text-lg ${p.state ? 'text-white' : 'text-slate-600'}`}>
{p.type === 'input' ? 'I' : 'Q'}{p.id.toString().padStart(2, '0')}
</div>
{/* Name */}
<div className={`flex-1 font-bold uppercase tracking-wide truncate ${p.state ? 'text-white' : 'text-slate-500'} group-hover:text-white transition-colors`}>
{p.name}
</div>
</div>
))}
</div>
</div>
</div>
</main>
);
};

View File

@@ -0,0 +1,173 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layers, Plus, Trash2, Copy, Save, ArrowLeft, FileText, Calendar } from 'lucide-react';
import { Recipe } from '../types';
import { comms } from '../communication';
import { TechButton } from '../components/common/TechButton';
export const RecipePage: React.FC = () => {
const navigate = useNavigate();
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [editedRecipe, setEditedRecipe] = useState<Recipe | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadRecipes = async () => {
try {
const recipeStr = await comms.getRecipeList();
const recipeData = JSON.parse(recipeStr);
setRecipes(recipeData);
if (recipeData.length > 0) {
setSelectedId(recipeData[0].id);
setEditedRecipe(recipeData[0]);
}
} catch (e) {
console.error("Failed to load recipes", e);
}
setIsLoading(false);
};
loadRecipes();
}, []);
const handleSelect = (r: Recipe) => {
setSelectedId(r.id);
setEditedRecipe({ ...r });
};
const handleSave = async () => {
// Mock Save Logic
console.log("Saving recipe:", editedRecipe);
// In real app: await comms.saveRecipe(editedRecipe);
};
return (
<div className="w-full h-full p-6 flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/')}
className="p-2 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-6 h-6" />
</button>
<h1 className="text-2xl font-tech font-bold text-white tracking-wider flex items-center gap-3">
<Layers className="text-neon-blue" /> RECIPE MANAGEMENT
</h1>
</div>
<div className="text-sm font-mono text-slate-400">
TOTAL: <span className="text-neon-blue">{recipes.length}</span>
</div>
</div>
<div className="flex-1 flex gap-6 min-h-0">
{/* Left: Recipe List */}
<div className="w-80 flex flex-col gap-4">
<div className="bg-slate-950/40 backdrop-blur-md border border-white/10 rounded-lg flex-1 overflow-hidden flex flex-col">
<div className="p-3 border-b border-white/10 bg-white/5 font-bold text-sm tracking-wider text-slate-300">
RECIPE LIST
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
{recipes.map(r => (
<div
key={r.id}
onClick={() => handleSelect(r)}
className={`
p-3 rounded border cursor-pointer transition-all group
${selectedId === r.id
? 'bg-neon-blue/20 border-neon-blue text-white shadow-glow-blue'
: 'bg-white/5 border-white/5 text-slate-400 hover:bg-white/10 hover:border-white/20'}
`}
>
<div className="font-bold text-sm mb-1">{r.name}</div>
<div className="text-[10px] font-mono opacity-60 flex items-center gap-2">
<Calendar className="w-3 h-3" /> {r.lastModified}
</div>
</div>
))}
</div>
{/* List Actions */}
<div className="p-3 border-t border-white/10 bg-black/20 grid grid-cols-3 gap-2">
<TechButton variant="default" className="flex justify-center" title="Add New">
<Plus className="w-4 h-4" />
</TechButton>
<TechButton variant="default" className="flex justify-center" title="Copy Selected">
<Copy className="w-4 h-4" />
</TechButton>
<TechButton variant="danger" className="flex justify-center" title="Delete Selected">
<Trash2 className="w-4 h-4" />
</TechButton>
</div>
</div>
</div>
{/* Right: Editor */}
<div className="flex-1 bg-slate-950/40 backdrop-blur-md border border-white/10 rounded-lg flex flex-col overflow-hidden">
<div className="p-3 border-b border-white/10 bg-white/5 font-bold text-sm tracking-wider text-slate-300 flex justify-between items-center">
<span>RECIPE EDITOR</span>
{editedRecipe && <span className="text-neon-blue font-mono">{editedRecipe.id}</span>}
</div>
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar">
{editedRecipe ? (
<div className="max-w-2xl space-y-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Recipe Name</label>
<input
type="text"
value={editedRecipe.name}
onChange={(e) => setEditedRecipe({ ...editedRecipe, name: e.target.value })}
className="w-full bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Description</label>
<textarea
className="w-full h-32 bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono resize-none"
placeholder="Enter recipe description..."
></textarea>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Process Time (sec)</label>
<input type="number" className="w-full bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono" defaultValue="120" />
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Temperature (°C)</label>
<input type="number" className="w-full bg-black/40 border border-white/10 rounded px-4 py-3 text-white focus:border-neon-blue focus:outline-none transition-colors font-mono" defaultValue="24.5" />
</div>
</div>
{/* Placeholder for more parameters */}
<div className="p-4 border border-dashed border-white/10 rounded bg-white/5 text-center text-slate-500 text-sm">
Additional process parameters would go here...
</div>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-slate-500">
<FileText className="w-16 h-16 mb-4 opacity-20" />
<p>Select a recipe to edit</p>
</div>
)}
</div>
{/* Editor Actions */}
<div className="p-4 border-t border-white/10 bg-black/20 flex justify-end">
<TechButton
variant="green"
className="flex items-center gap-2 px-8"
onClick={handleSave}
disabled={!editedRecipe}
>
<Save className="w-4 h-4" /> SAVE CHANGES
</TechButton>
</div>
</div>
</div>
</div>
);
};

View File

@@ -61,6 +61,8 @@ declare global {
SystemControl(command: string): Promise<void>;
LoadRecipe(recipeId: string): Promise<void>;
GetConfig(): Promise<string>;
GetIOList(): Promise<string>;
GetRecipeList(): Promise<string>;
SaveConfig(configJson: string): Promise<void>;
}
};