Initial commit: Industrial HMI system with component architecture
- Implement WebView2-based HMI frontend with React + TypeScript + Vite - Add C# .NET backend with WebSocket communication layer - Separate UI components into modular structure: * RecipePanel: Recipe selection and management * IOPanel: I/O monitoring and control (32 inputs/outputs) * MotionPanel: Servo control for X/Y/Z axes * CameraPanel: Vision system feed with HUD overlay * SettingsModal: System configuration management - Create reusable UI components (CyberPanel, TechButton, PanelHeader) - Implement dual-mode communication (WebView2 native + WebSocket fallback) - Add 3D visualization with Three.js/React Three Fiber - Fix JSON parsing bug in configuration save handler - Include comprehensive .gitignore for .NET and Node.js projects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
388
frontend/App.tsx
Normal file
388
frontend/App.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
|
||||
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 { 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) => {
|
||||
let name = `DOUT_${i.toString().padStart(2, '0')}`;
|
||||
if (i === 0) name = "Tower Lamp Red";
|
||||
if (i === 1) name = "Tower Lamp Yel";
|
||||
if (i === 2) name = "Tower Lamp Grn";
|
||||
return { id: i, name, type: 'output' as const, state: false };
|
||||
}),
|
||||
...Array.from({ length: 32 }, (_, i) => {
|
||||
let name = `DIN_${i.toString().padStart(2, '0')}`;
|
||||
let 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; }
|
||||
return { id: i, name, type: 'input' as const, state: initialState };
|
||||
})
|
||||
];
|
||||
|
||||
// --- MAIN APP ---
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState<'recipe' | 'io' | 'motion' | 'camera' | 'setting' | null>(null);
|
||||
const [systemState, setSystemState] = useState<SystemState>(SystemState.IDLE);
|
||||
const [currentRecipe, setCurrentRecipe] = useState<Recipe>(MOCK_RECIPES[0]);
|
||||
const [robotTarget, setRobotTarget] = useState<RobotTarget>({ x: 0, y: 0, z: 0 });
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [ioPoints, setIoPoints] = useState<IOPoint[]>(INITIAL_IO);
|
||||
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;
|
||||
|
||||
// -- COMMUNICATION LAYER --
|
||||
useEffect(() => {
|
||||
// Subscribe to unified communication layer
|
||||
const unsubscribe = comms.subscribe((msg: any) => {
|
||||
if (!msg) return;
|
||||
|
||||
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];
|
||||
msg.ioState.forEach((update: { id: number, type: string, state: boolean }) => {
|
||||
const idx = newIO.findIndex(p => p.id === update.id && p.type === update.type);
|
||||
if (idx >= 0) newIO[idx] = { ...newIO[idx], state: update.state };
|
||||
});
|
||||
return newIO;
|
||||
});
|
||||
}
|
||||
// Update System State
|
||||
if (msg.sysState) {
|
||||
setSystemState(msg.sysState as SystemState);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addLog("COMMUNICATION CHANNEL OPEN", "info");
|
||||
|
||||
// Timer for Clock
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// -- INITIALIZATION --
|
||||
useEffect(() => {
|
||||
const initSystem = async () => {
|
||||
// Just start up without fetching config
|
||||
addLog("SYSTEM STARTED", "info");
|
||||
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();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
// Logic Helpers
|
||||
const doorStates = {
|
||||
front: ioPoints.find(p => p.id === 0 && p.type === 'input')?.state || false,
|
||||
right: ioPoints.find(p => p.id === 1 && p.type === 'input')?.state || false,
|
||||
left: ioPoints.find(p => p.id === 2 && p.type === 'input')?.state || false,
|
||||
back: ioPoints.find(p => p.id === 3 && p.type === 'input')?.state || false,
|
||||
};
|
||||
const isLowPressure = !(ioPoints.find(p => p.id === 4 && p.type === 'input')?.state ?? true);
|
||||
const isEmergencyStop = !(ioPoints.find(p => p.id === 6 && p.type === 'input')?.state ?? true);
|
||||
|
||||
// -- COMMAND HANDLERS --
|
||||
|
||||
// -- COMMAND HANDLERS --
|
||||
|
||||
const handleControl = async (action: 'start' | 'stop' | 'reset') => {
|
||||
if (isEmergencyStop && action === 'start') return addLog('EMERGENCY STOP ACTIVE', 'error');
|
||||
|
||||
try {
|
||||
await comms.sendControl(action.toUpperCase());
|
||||
addLog(`CMD SENT: ${action.toUpperCase()}`, 'info');
|
||||
} catch (e) {
|
||||
addLog('COMM ERROR', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
await comms.setIO(id, nextState);
|
||||
}
|
||||
};
|
||||
|
||||
const moveAxis = async (axis: 'X' | 'Y' | 'Z', value: number) => {
|
||||
if (isEmergencyStop) return;
|
||||
await comms.moveAxis(axis, value);
|
||||
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");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
addLog("FAILED TO SAVE CONFIG", "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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user