drone manager created (wip)
This commit is contained in:
parent
3904c19bbf
commit
c6d0c66563
@ -9,6 +9,7 @@ import ProjectManager from './pages/ProjectManager';
|
|||||||
import SignIn from './pages/SignIn';
|
import SignIn from './pages/SignIn';
|
||||||
import SignUp from './pages/SignUp';
|
import SignUp from './pages/SignUp';
|
||||||
import ChatSessionView from './pages/ChatSessionView';
|
import ChatSessionView from './pages/ChatSessionView';
|
||||||
|
import DroneManager from './pages/DroneManager';
|
||||||
|
|
||||||
const TOKEN_KEY = 'dtp_auth_token';
|
const TOKEN_KEY = 'dtp_auth_token';
|
||||||
const USER_KEY = 'dtp_user';
|
const USER_KEY = 'dtp_user';
|
||||||
@ -117,6 +118,7 @@ export default function App() {
|
|||||||
<Route path="/projects" element={<ProjectManager user={user} />} />
|
<Route path="/projects" element={<ProjectManager user={user} />} />
|
||||||
<Route path="/projects/new" element={<ProjectManager user={user} />} />
|
<Route path="/projects/new" element={<ProjectManager user={user} />} />
|
||||||
<Route path="/projects/:slug" element={<ProjectManager user={user} />} />
|
<Route path="/projects/:slug" element={<ProjectManager user={user} />} />
|
||||||
|
<Route path="/drones" element={<DroneManager user={user} />} />
|
||||||
<Route path="/projects/:projectId/chat-session/:sessionId" element={<ChatSessionView />} />
|
<Route path="/projects/:projectId/chat-session/:sessionId" element={<ChatSessionView />} />
|
||||||
<Route
|
<Route
|
||||||
path="/sign-in"
|
path="/sign-in"
|
||||||
|
|||||||
@ -121,6 +121,8 @@ export interface DroneRegistration {
|
|||||||
|
|
||||||
export const droneApi = {
|
export const droneApi = {
|
||||||
getAll: () => api.get<DroneRegistration[]>('/api/v1/drone/registration'),
|
getAll: () => api.get<DroneRegistration[]>('/api/v1/drone/registration'),
|
||||||
|
getForManager: () => api.get<DroneRegistration[]>('/api/v1/drone/registration/manager'),
|
||||||
|
terminate: (registrationId: string) => api.post<ApiResponse>(`/api/v1/drone/registration/${registrationId}/terminate`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AiModelCapabilities {
|
export interface AiModelCapabilities {
|
||||||
|
|||||||
391
gadget-code/frontend/src/pages/DroneManager.tsx
Normal file
391
gadget-code/frontend/src/pages/DroneManager.tsx
Normal file
@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -163,8 +163,18 @@ function DashboardSidebar({ onNavigate, selectedDrone, onSelectDrone }: Dashboar
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 border-t border-border-subtle">
|
<div className="p-3 border-t border-border-subtle">
|
||||||
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
<div className="flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||||
Drones
|
<span>Drones</span>
|
||||||
|
<Link
|
||||||
|
to="/drones"
|
||||||
|
className="text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
title="Drone Manager"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-text-muted px-2">Loading...</p>
|
<p className="text-sm text-text-muted px-2">Loading...</p>
|
||||||
@ -172,9 +182,7 @@ function DashboardSidebar({ onNavigate, selectedDrone, onSelectDrone }: Dashboar
|
|||||||
<p className="text-sm text-text-muted px-2">No drones available.</p>
|
<p className="text-sm text-text-muted px-2">No drones available.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{drones
|
{drones.map((drone) => (
|
||||||
.filter((d) => d.status === 'available' || d.status === 'busy')
|
|
||||||
.map((drone) => (
|
|
||||||
<button
|
<button
|
||||||
key={drone._id}
|
key={drone._id}
|
||||||
onClick={() => onSelectDrone(drone)}
|
onClick={() => onSelectDrone(drone)}
|
||||||
|
|||||||
@ -191,9 +191,8 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Load available drones only (filter out offline)
|
// Load available drones (backend filters offline)
|
||||||
const allDrones = await droneApi.getAll();
|
const availableDrones = await droneApi.getAll();
|
||||||
const availableDrones = allDrones.filter(d => d.status !== 'offline');
|
|
||||||
setDrones(availableDrones);
|
setDrones(availableDrones);
|
||||||
|
|
||||||
// Load chat sessions for this project
|
// Load chat sessions for this project
|
||||||
|
|||||||
@ -3,7 +3,9 @@
|
|||||||
// All Rights Reserved
|
// All Rights Reserved
|
||||||
|
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
|
||||||
|
import { DroneStatus } from "@gadget/api";
|
||||||
import DroneService from "../../../services/drone.ts";
|
import DroneService from "../../../services/drone.ts";
|
||||||
import UserService from "../../../services/user.ts";
|
import UserService from "../../../services/user.ts";
|
||||||
|
|
||||||
@ -48,6 +50,18 @@ export class DroneApiControllerV1 extends DtpController {
|
|||||||
this.getRegistrations.bind(this),
|
this.getRegistrations.bind(this),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.router.get(
|
||||||
|
"/registration/manager",
|
||||||
|
requireUser,
|
||||||
|
this.getManagerRegistrations.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.router.post(
|
||||||
|
"/registration/:registrationId/terminate",
|
||||||
|
requireUser,
|
||||||
|
this.postTerminate.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
this.router.delete(
|
this.router.delete(
|
||||||
"/registration/:registrationId",
|
"/registration/:registrationId",
|
||||||
this.deleteRegistration.bind(this),
|
this.deleteRegistration.bind(this),
|
||||||
@ -92,7 +106,10 @@ export class DroneApiControllerV1 extends DtpController {
|
|||||||
|
|
||||||
async getRegistrations(req: Request, res: Response): Promise<void> {
|
async getRegistrations(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const registrations = await DroneService.getForUser(req.user);
|
const registrations = await DroneService.getForUser(req.user, [
|
||||||
|
DroneStatus.Starting,
|
||||||
|
DroneStatus.Offline,
|
||||||
|
]);
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: registrations,
|
data: registrations,
|
||||||
@ -108,6 +125,46 @@ export class DroneApiControllerV1 extends DtpController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getManagerRegistrations(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const registrations = await DroneService.getForManager(req.user);
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: registrations,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to present drone manager registrations for User", {
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
res.status((error as Error).statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postTerminate(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const registrationId = Types.ObjectId.createFromHexString(
|
||||||
|
req.params.registrationId as string,
|
||||||
|
);
|
||||||
|
const result = await DroneService.requestTermination(registrationId);
|
||||||
|
res.status(200).json({
|
||||||
|
success: result.success,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to terminate drone", {
|
||||||
|
registrationId: req.params.registrationId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
res.status((error as Error).statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deleteRegistration(_req: Request, res: Response): Promise<void> {
|
async deleteRegistration(_req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await DroneService.unregister(res.locals.registration);
|
await DroneService.unregister(res.locals.registration);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { IUser, DroneStatus, IDroneRegistration } from "@gadget/api";
|
|||||||
import DroneRegistration from "../models/drone-registration.ts";
|
import DroneRegistration from "../models/drone-registration.ts";
|
||||||
|
|
||||||
import { DtpService } from "../lib/service.ts";
|
import { DtpService } from "../lib/service.ts";
|
||||||
|
import SocketService from "./socket.ts";
|
||||||
|
|
||||||
export interface IDroneDefinition {
|
export interface IDroneDefinition {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
@ -105,15 +106,136 @@ class DroneService extends DtpService {
|
|||||||
/**
|
/**
|
||||||
* Request a User's list of registered drones.
|
* Request a User's list of registered drones.
|
||||||
* @param user The user for which a list of registered drones is being requested
|
* @param user The user for which a list of registered drones is being requested
|
||||||
|
* @param statusFilter Optional array of statuses to exclude from the results
|
||||||
* @returns The list of drone registrations for the user.
|
* @returns The list of drone registrations for the user.
|
||||||
*/
|
*/
|
||||||
async getForUser(user: IUser): Promise<IDroneRegistration[]> {
|
async getForUser(
|
||||||
const registrations = await DroneRegistration.find({ user: user._id })
|
user: IUser,
|
||||||
|
statusFilter?: DroneStatus[],
|
||||||
|
): Promise<IDroneRegistration[]> {
|
||||||
|
const query: Record<string, unknown> = { user: user._id };
|
||||||
|
|
||||||
|
if (statusFilter && statusFilter.length > 0) {
|
||||||
|
query.status = { $nin: statusFilter };
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrations = await DroneRegistration.find(query)
|
||||||
.sort({ hostname: 1, workspaceDir: 1 })
|
.sort({ hostname: 1, workspaceDir: 1 })
|
||||||
.populate(this.populateDroneRegistration);
|
.populate(this.populateDroneRegistration);
|
||||||
return registrations;
|
return registrations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a User's complete list of drone registrations for the Drone Manager.
|
||||||
|
* Returns all drones (online and offline) without filtering.
|
||||||
|
* @param user The user for which a list of drone registrations is being requested
|
||||||
|
* @returns The complete list of drone registrations for the user.
|
||||||
|
*/
|
||||||
|
async getForManager(user: IUser): Promise<IDroneRegistration[]> {
|
||||||
|
const registrations = await DroneRegistration.find({ user: user._id })
|
||||||
|
.sort({ status: 1, hostname: 1, workspaceDir: 1 })
|
||||||
|
.populate(this.populateDroneRegistration);
|
||||||
|
return registrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a drone to terminate itself. Sets a 10-second timeout to wait for
|
||||||
|
* the drone to mark itself as offline. If the timeout expires, forces the
|
||||||
|
* status to offline.
|
||||||
|
* @param registrationId The _id of the drone registration to terminate
|
||||||
|
* @returns Promise that resolves when termination is complete (success or timeout)
|
||||||
|
*/
|
||||||
|
async requestTermination(
|
||||||
|
registrationId: Types.ObjectId,
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const registration = await this.getById(registrationId);
|
||||||
|
|
||||||
|
if (registration.status === DroneStatus.Offline) {
|
||||||
|
return { success: false, message: "Drone is already offline" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let droneSession: any;
|
||||||
|
try {
|
||||||
|
droneSession = SocketService.getDroneSession(registration);
|
||||||
|
} catch (error) {
|
||||||
|
// Drone is not connected - mark as offline immediately
|
||||||
|
this.log.warn("drone not connected, marking offline", {
|
||||||
|
registrationId: registrationId.toHexString(),
|
||||||
|
hostname: registration.hostname,
|
||||||
|
});
|
||||||
|
await this.setStatus(registration, DroneStatus.Offline);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Drone was not connected. Marked offline.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let resolved = false;
|
||||||
|
let timeoutHandle: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
resolved = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
timeoutHandle = setTimeout(async () => {
|
||||||
|
if (resolved) return;
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
this.log.warn("drone termination timed out, forcing offline", {
|
||||||
|
registrationId: registrationId.toHexString(),
|
||||||
|
hostname: registration.hostname,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.setStatus(registration, DroneStatus.Offline);
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
message: "Drone termination timed out. Marked offline.",
|
||||||
|
});
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
droneSession.socket.emit(
|
||||||
|
"requestTermination",
|
||||||
|
async (success: boolean) => {
|
||||||
|
if (resolved) return;
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
cleanup();
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
message: "Drone rejected termination request",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.info("drone accepted termination request", {
|
||||||
|
registrationId: registrationId.toHexString(),
|
||||||
|
hostname: registration.hostname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const updated = await this.getById(registrationId);
|
||||||
|
if (updated.status === DroneStatus.Offline) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
cleanup();
|
||||||
|
resolve({ success: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
cleanup();
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
message: "Drone termination timed out. Marked offline.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The drone calls this to update it's status in the database.
|
* The drone calls this to update it's status in the database.
|
||||||
* @param registration The registration for which a status is being updated.
|
* @param registration The registration for which a status is being updated.
|
||||||
|
|||||||
14
gadget-drone/.gadget/workspace.json
Normal file
14
gadget-drone/.gadget/workspace.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"workspaceId": "9737cffd-ae5b-418f-b181-e30553bec83d",
|
||||||
|
"createdAt": "2026-04-30T12:37:32.429Z",
|
||||||
|
"hostname": "mysterymachine",
|
||||||
|
"workspaceDir": "/home/rob/projects/gadget/gadget-drone",
|
||||||
|
"chatSession": null,
|
||||||
|
"lockedProject": null,
|
||||||
|
"projects": [],
|
||||||
|
"registration": {
|
||||||
|
"_id": "69f3533069d00859987b4e56",
|
||||||
|
"status": "available",
|
||||||
|
"registeredAt": "2026-04-30T13:03:45.023Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -122,13 +122,22 @@ class GadgetDrone extends GadgetProcess {
|
|||||||
|
|
||||||
async stop(): Promise<number> {
|
async stop(): Promise<number> {
|
||||||
this.log.info(`Gadget Drone v${env.pkg.version} shutting down`);
|
this.log.info(`Gadget Drone v${env.pkg.version} shutting down`);
|
||||||
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.disconnect();
|
this.socket.disconnect();
|
||||||
delete this.socket;
|
delete this.socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await PlatformService.unregister();
|
await PlatformService.unregister();
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to unregister drone from Gadget Code platform", {
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
|
||||||
await this.stopServices();
|
await this.stopServices();
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
@ -191,6 +200,10 @@ class GadgetDrone extends GadgetProcess {
|
|||||||
this.onRequestWorkspaceMode.bind(this),
|
this.onRequestWorkspaceMode.bind(this),
|
||||||
);
|
);
|
||||||
this.socket.on("processWorkOrder", this.onProcessWorkOrder.bind(this));
|
this.socket.on("processWorkOrder", this.onProcessWorkOrder.bind(this));
|
||||||
|
this.socket.on(
|
||||||
|
"requestTermination",
|
||||||
|
this.onRequestTermination.bind(this),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -357,6 +370,16 @@ class GadgetDrone extends GadgetProcess {
|
|||||||
chatSessionId: cache.chatSessionId,
|
chatSessionId: cache.chatSessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onRequestTermination(cb: (success: boolean) => void): Promise<void> {
|
||||||
|
this.log.info("requestTermination received from platform", {
|
||||||
|
registrationId: this.registration?._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
cb(true);
|
||||||
|
|
||||||
|
process.kill(process.pid, "SIGINT");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@ -47,3 +47,7 @@ export type CrashRecoveryResponseMessage = (data: {
|
|||||||
action: "discard" | "retry";
|
action: "discard" | "retry";
|
||||||
retryDelay?: number;
|
retryDelay?: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
|
export type RequestTerminationMessage = (
|
||||||
|
cb: (success: boolean) => void,
|
||||||
|
) => void;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
WorkOrderCompleteMessage,
|
WorkOrderCompleteMessage,
|
||||||
RequestCrashRecoveryMessage,
|
RequestCrashRecoveryMessage,
|
||||||
CrashRecoveryResponseMessage,
|
CrashRecoveryResponseMessage,
|
||||||
|
RequestTerminationMessage,
|
||||||
} from "./drone.ts";
|
} from "./drone.ts";
|
||||||
import {
|
import {
|
||||||
RequestSessionLockMessage,
|
RequestSessionLockMessage,
|
||||||
@ -53,6 +54,7 @@ export interface ClientToServerEvents {
|
|||||||
toolCall: ToolCallMessage;
|
toolCall: ToolCallMessage;
|
||||||
workOrderComplete: WorkOrderCompleteMessage;
|
workOrderComplete: WorkOrderCompleteMessage;
|
||||||
requestCrashRecovery: RequestCrashRecoveryMessage;
|
requestCrashRecovery: RequestCrashRecoveryMessage;
|
||||||
|
requestTermination: RequestTerminationMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerToClientEvents {
|
export interface ServerToClientEvents {
|
||||||
@ -64,6 +66,7 @@ export interface ServerToClientEvents {
|
|||||||
requestWorkspaceMode: RequestWorkspaceModeMessage;
|
requestWorkspaceMode: RequestWorkspaceModeMessage;
|
||||||
processWorkOrder: ProcessWorkOrderMessage;
|
processWorkOrder: ProcessWorkOrderMessage;
|
||||||
crashRecoveryResponse: CrashRecoveryResponseMessage;
|
crashRecoveryResponse: CrashRecoveryResponseMessage;
|
||||||
|
requestTermination: RequestTerminationMessage;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* gadget-code:web => gadget-code:ide
|
* gadget-code:web => gadget-code:ide
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user