gadget/gadget-code/frontend/src/pages/DroneManager.tsx
2026-04-30 09:40:27 -04:00

392 lines
16 KiB
TypeScript

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<DroneRegistration[]>([]);
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
const [loading, setLoading] = useState(true);
const [terminating, setTerminating] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
// Placeholder log entries for testing scroll behavior
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const logContainerRef = useRef<HTMLDivElement>(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 (
<div className="flex-1 flex items-center justify-center bg-bg-primary">
<p className="text-text-muted">Please sign in to view drones.</p>
</div>
);
}
return (
<div className="flex-1 flex bg-bg-primary overflow-hidden">
{/* Toast Notification */}
{toast && (
<div className={`fixed top-16 right-4 z-50 px-4 py-3 rounded border ${
toast.type === 'success'
? 'bg-green-900/80 border-green-600 text-green-100'
: 'bg-red-900/80 border-red-600 text-red-100'
}`}>
{toast.message}
</div>
)}
{/* Left Panel - Drone Lists */}
<aside className="w-80 border-r border-border-subtle bg-bg-secondary flex flex-col overflow-hidden">
<div className="p-3 border-b border-border-subtle flex-shrink-0">
<h2 className="text-lg font-semibold text-text-primary">Drone Manager</h2>
</div>
{/* Online Drones Section - 50% of list area */}
<div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}>
<div className="p-2 border-b border-border-subtle flex-shrink-0 bg-bg-tertiary">
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
Online Drones ({onlineDrones.length})
</h3>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2 min-h-0">
{loading ? (
<p className="text-sm text-text-muted p-2">Loading...</p>
) : onlineDrones.length === 0 ? (
<div className="text-text-muted text-sm p-2">
No online drones.
</div>
) : (
onlineDrones.map((drone) => (
<div
key={drone._id}
onClick={() => handleSelectDrone(drone)}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedDrone?._id === drone._id
? 'border-brand bg-bg-elevated'
: 'border-border-default bg-bg-tertiary hover:bg-bg-elevated'
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${
drone.status === 'available'
? 'bg-green-500'
: 'bg-yellow-500'
}`}
/>
<span className="font-mono text-sm font-medium text-text-primary">
{drone.hostname}
</span>
</div>
<span className={`text-xs px-2 py-0.5 rounded ${
drone.status === 'available'
? 'bg-green-900/50 text-green-400'
: 'bg-yellow-900/50 text-yellow-400'
}`}>
{drone.status}
</span>
</div>
<div className="text-xs text-text-muted truncate">
{drone.workspaceDir}
</div>
</div>
))
)}
</div>
</div>
{/* Offline Drones Section - 50% of list area */}
<div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}>
<div className="p-2 border-t border-border-subtle flex-shrink-0 bg-bg-tertiary">
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
Offline Drones ({offlineDrones.length})
</h3>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2 min-h-0">
{loading ? (
<p className="text-sm text-text-muted p-2">Loading...</p>
) : offlineDrones.length === 0 ? (
<div className="text-text-muted text-sm p-2">
No offline drones.
</div>
) : (
offlineDrones.map((drone) => (
<div
key={drone._id}
onClick={() => handleSelectDrone(drone)}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedDrone?._id === drone._id
? 'border-brand bg-bg-elevated'
: 'border-border-default bg-bg-tertiary hover:bg-bg-elevated'
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-gray-500" />
<span className="font-mono text-sm font-medium text-text-primary">
{drone.hostname}
</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-gray-900/50 text-gray-400">
{drone.status}
</span>
</div>
<div className="text-xs text-text-muted truncate">
{drone.workspaceDir}
</div>
</div>
))
)}
</div>
</div>
</aside>
{/* Right Panel - Drone Details */}
<main className="flex-1 flex flex-col overflow-hidden bg-bg-primary">
{selectedDrone ? (
<>
{/* Drone Info Card */}
<div className="p-6 border-b border-border-subtle flex-shrink-0">
<div className="max-w-3xl">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-text-primary">Drone Details</h2>
{selectedDrone.status !== 'offline' && (
<button
onClick={handleTerminate}
disabled={terminating === selectedDrone._id}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
{terminating === selectedDrone._id ? 'Terminating...' : 'Terminate'}
</button>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Hostname</div>
<div className="font-mono text-text-primary">{selectedDrone.hostname}</div>
</div>
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Status</div>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${
selectedDrone.status === 'available'
? 'bg-green-500'
: selectedDrone.status === 'busy'
? 'bg-yellow-500'
: 'bg-gray-500'
}`}
/>
<span className="text-text-primary capitalize">{selectedDrone.status}</span>
</div>
</div>
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Workspace</div>
<div className="font-mono text-text-primary text-sm truncate">
{selectedDrone.workspaceDir}
</div>
</div>
<div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Registered</div>
<div className="text-text-primary">
{new Date(selectedDrone.createdAt).toLocaleString()}
</div>
</div>
</div>
</div>
</div>
{/* Drone Monitor Placeholder */}
<div className="flex-1 overflow-y-auto p-6 min-h-0">
<div className="max-w-3xl mb-6">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
Drone Monitor
</h3>
<div className="p-8 bg-bg-secondary border border-border-default rounded text-center">
<p className="text-text-muted">
Monitor charts coming soon.
</p>
<p className="text-text-muted text-sm mt-2">
Memory usage, AI operations, and log production metrics will be displayed here.
</p>
</div>
</div>
{/* Log Viewer - Collapsible panel at bottom */}
<div className="flex flex-col min-h-[300px] max-h-[400px] border-t border-border-subtle">
<div className="p-2 bg-bg-secondary border-b border-border-subtle flex-shrink-0">
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
Drone Log {selectedDrone.status !== 'offline' && '(Live)'}
</h3>
</div>
<div
ref={logContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-3 bg-bg-primary font-mono text-xs min-h-0"
>
{logEntries.length === 0 ? (
<div className="text-text-muted">
<p>No log entries yet.</p>
<p className="mt-2 text-text-secondary">
{/* TODO: Remove this placeholder comment when live logs are implemented */}
[PLACEHOLDER] Log entries will appear here when the drone is running.
</p>
</div>
) : (
logEntries.map((entry) => (
<div key={entry.id} className="py-1 border-b border-border-subtle last:border-0">
<span className="text-text-muted mr-3">[{entry.timestamp}]</span>
<span className="text-text-secondary">{entry.message}</span>
</div>
))
)}
</div>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-text-muted">
<div className="text-center">
<p className="mb-2">Select a drone to view details</p>
<p className="text-sm">Choose from the online or offline drone lists</p>
</div>
</div>
)}
</main>
</div>
);
}