diff --git a/gadget-code/frontend/src/App.tsx b/gadget-code/frontend/src/App.tsx index a725bff..2dd48cd 100644 --- a/gadget-code/frontend/src/App.tsx +++ b/gadget-code/frontend/src/App.tsx @@ -9,6 +9,7 @@ import ProjectManager from './pages/ProjectManager'; import SignIn from './pages/SignIn'; import SignUp from './pages/SignUp'; import ChatSessionView from './pages/ChatSessionView'; +import DroneManager from './pages/DroneManager'; const TOKEN_KEY = 'dtp_auth_token'; const USER_KEY = 'dtp_user'; @@ -117,6 +118,7 @@ export default function App() { } /> } /> } /> + } /> } /> api.get('/api/v1/drone/registration'), + getForManager: () => api.get('/api/v1/drone/registration/manager'), + terminate: (registrationId: string) => api.post(`/api/v1/drone/registration/${registrationId}/terminate`), }; export interface AiModelCapabilities { diff --git a/gadget-code/frontend/src/pages/DroneManager.tsx b/gadget-code/frontend/src/pages/DroneManager.tsx new file mode 100644 index 0000000..326afb2 --- /dev/null +++ b/gadget-code/frontend/src/pages/DroneManager.tsx @@ -0,0 +1,391 @@ +import { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { User, DroneRegistration } from '../lib/api'; +import { droneApi } from '../lib/api'; + +interface DroneManagerProps { + user: User | null; +} + +interface LogEntry { + id: number; + timestamp: string; + message: string; +} + +export default function DroneManager({ user }: DroneManagerProps) { + const navigate = useNavigate(); + const [allDrones, setAllDrones] = useState([]); + const [selectedDrone, setSelectedDrone] = useState(null); + const [loading, setLoading] = useState(true); + const [terminating, setTerminating] = useState(null); + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); + + // Placeholder log entries for testing scroll behavior + const [logEntries, setLogEntries] = useState([]); + const logContainerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + useEffect(() => { + if (!user) { + navigate('/sign-in'); + return; + } + loadDrones(); + }, [user]); + + // Mock log generator for testing scroll behavior + // TODO: Replace with live log streaming when backend is implemented + useEffect(() => { + if (!selectedDrone) { + setLogEntries([]); + return; + } + + // Initialize with some placeholder entries + const initialEntries: LogEntry[] = Array.from({ length: 50 }, (_, i) => ({ + id: i, + timestamp: new Date(Date.now() - (50 - i) * 1000).toLocaleTimeString(), + message: `[PLACEHOLDER] Log entry ${i + 1} - This is mock data for testing scroll behavior`, + })); + setLogEntries(initialEntries); + + // Add a new entry every second for testing auto-scroll + const interval = setInterval(() => { + setLogEntries(prev => { + const newEntry: LogEntry = { + id: prev.length > 0 ? prev[prev.length - 1].id + 1 : 0, + timestamp: new Date().toLocaleTimeString(), + message: `[PLACEHOLDER] New log entry at ${new Date().toLocaleTimeString()} - Auto-generated for testing`, + }; + const updated = [...prev, newEntry]; + // Keep only last 200 entries as per spec + if (updated.length > 200) { + return updated.slice(updated.length - 200); + } + return updated; + }); + }, 1000); + + return () => clearInterval(interval); + }, [selectedDrone]); + + // Auto-scroll log to bottom when new entries arrive (if user hasn't scrolled up) + useEffect(() => { + if (autoScroll && logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [logEntries, autoScroll]); + + const handleScroll = () => { + if (logContainerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current; + // Check if user is near the bottom (within 100px) + const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; + setAutoScroll(isNearBottom); + } + }; + + const loadDrones = async () => { + try { + const data = await droneApi.getForManager(); + setAllDrones(data); + } catch (err) { + console.error('Failed to load drones', err); + showToast('Failed to load drones', 'error'); + } finally { + setLoading(false); + } + }; + + const showToast = (message: string, type: 'success' | 'error') => { + setToast({ message, type }); + setTimeout(() => setToast(null), 5000); + }; + + const handleSelectDrone = (drone: DroneRegistration) => { + setSelectedDrone(drone); + setLogEntries([]); // Clear log when switching drones + }; + + const handleTerminate = async () => { + if (!selectedDrone) return; + + // Show confirmation if drone is busy + if (selectedDrone.status === 'busy') { + const confirmed = confirm('Are you sure you want to terminate the drone? Any work or operations currently in progress may be lost.'); + if (!confirmed) return; + } + + setTerminating(selectedDrone._id); + try { + const result = await droneApi.terminate(selectedDrone._id); + + if (result.message) { + showToast(result.message, result.success ? 'success' : 'error'); + } else { + showToast('Drone terminated successfully', 'success'); + } + + // Refresh drone list + await loadDrones(); + + // Clear selection if drone is now offline + if (selectedDrone.status !== 'offline') { + setSelectedDrone(null); + } + } catch (err) { + console.error('Failed to terminate drone', err); + showToast('Failed to terminate drone', 'error'); + } finally { + setTerminating(null); + } + }; + + const onlineDrones = allDrones.filter(d => d.status === 'available' || d.status === 'busy'); + const offlineDrones = allDrones.filter(d => d.status === 'offline' || d.status === 'starting'); + + if (!user) { + return ( +
+

Please sign in to view drones.

+
+ ); + } + + return ( +
+ {/* Toast Notification */} + {toast && ( +
+ {toast.message} +
+ )} + + {/* Left Panel - Drone Lists */} + + + {/* Right Panel - Drone Details */} +
+ {selectedDrone ? ( + <> + {/* Drone Info Card */} +
+
+
+

Drone Details

+ {selectedDrone.status !== 'offline' && ( + + )} +
+ +
+
+
Hostname
+
{selectedDrone.hostname}
+
+
+
Status
+
+ + {selectedDrone.status} +
+
+
+
Workspace
+
+ {selectedDrone.workspaceDir} +
+
+
+
Registered
+
+ {new Date(selectedDrone.createdAt).toLocaleString()} +
+
+
+
+
+ + {/* Drone Monitor Placeholder */} +
+
+

+ Drone Monitor +

+
+

+ Monitor charts coming soon. +

+

+ Memory usage, AI operations, and log production metrics will be displayed here. +

+
+
+ + {/* Log Viewer - Collapsible panel at bottom */} +
+
+

+ Drone Log {selectedDrone.status !== 'offline' && '(Live)'} +

+
+
+ {logEntries.length === 0 ? ( +
+

No log entries yet.

+

+ {/* TODO: Remove this placeholder comment when live logs are implemented */} + [PLACEHOLDER] Log entries will appear here when the drone is running. +

+
+ ) : ( + logEntries.map((entry) => ( +
+ [{entry.timestamp}] + {entry.message} +
+ )) + )} +
+
+
+ + ) : ( +
+
+

Select a drone to view details

+

Choose from the online or offline drone lists

+
+
+ )} +
+
+ ); +} diff --git a/gadget-code/frontend/src/pages/Home.tsx b/gadget-code/frontend/src/pages/Home.tsx index 9f78637..d73240a 100644 --- a/gadget-code/frontend/src/pages/Home.tsx +++ b/gadget-code/frontend/src/pages/Home.tsx @@ -163,8 +163,18 @@ function DashboardSidebar({ onNavigate, selectedDrone, onSelectDrone }: Dashboar
-
- Drones +
+ Drones + + + + + +
{loading ? (

Loading...

@@ -172,9 +182,7 @@ function DashboardSidebar({ onNavigate, selectedDrone, onSelectDrone }: Dashboar

No drones available.

) : (
- {drones - .filter((d) => d.status === 'available' || d.status === 'busy') - .map((drone) => ( + {drones.map((drone) => (