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 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() {
|
||||
<Route path="/projects" element={<ProjectManager user={user} />} />
|
||||
<Route path="/projects/new" 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="/sign-in"
|
||||
|
||||
@ -121,6 +121,8 @@ export interface DroneRegistration {
|
||||
|
||||
export const droneApi = {
|
||||
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 {
|
||||
|
||||
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 className="p-3 border-t border-border-subtle">
|
||||
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
Drones
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
<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>
|
||||
{loading ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{drones
|
||||
.filter((d) => d.status === 'available' || d.status === 'busy')
|
||||
.map((drone) => (
|
||||
{drones.map((drone) => (
|
||||
<button
|
||||
key={drone._id}
|
||||
onClick={() => onSelectDrone(drone)}
|
||||
|
||||
@ -191,9 +191,8 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Load available drones only (filter out offline)
|
||||
const allDrones = await droneApi.getAll();
|
||||
const availableDrones = allDrones.filter(d => d.status !== 'offline');
|
||||
// Load available drones (backend filters offline)
|
||||
const availableDrones = await droneApi.getAll();
|
||||
setDrones(availableDrones);
|
||||
|
||||
// Load chat sessions for this project
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
// All Rights Reserved
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { Types } from "mongoose";
|
||||
|
||||
import { DroneStatus } from "@gadget/api";
|
||||
import DroneService from "../../../services/drone.ts";
|
||||
import UserService from "../../../services/user.ts";
|
||||
|
||||
@ -48,6 +50,18 @@ export class DroneApiControllerV1 extends DtpController {
|
||||
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(
|
||||
"/registration/:registrationId",
|
||||
this.deleteRegistration.bind(this),
|
||||
@ -92,7 +106,10 @@ export class DroneApiControllerV1 extends DtpController {
|
||||
|
||||
async getRegistrations(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const registrations = await DroneService.getForUser(req.user);
|
||||
const registrations = await DroneService.getForUser(req.user, [
|
||||
DroneStatus.Starting,
|
||||
DroneStatus.Offline,
|
||||
]);
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
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> {
|
||||
try {
|
||||
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 { DtpService } from "../lib/service.ts";
|
||||
import SocketService from "./socket.ts";
|
||||
|
||||
export interface IDroneDefinition {
|
||||
hostname: string;
|
||||
@ -105,15 +106,136 @@ class DroneService extends DtpService {
|
||||
/**
|
||||
* Request a User's list of registered drones.
|
||||
* @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.
|
||||
*/
|
||||
async getForUser(user: IUser): Promise<IDroneRegistration[]> {
|
||||
const registrations = await DroneRegistration.find({ user: user._id })
|
||||
async getForUser(
|
||||
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 })
|
||||
.populate(this.populateDroneRegistration);
|
||||
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.
|
||||
* @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> {
|
||||
this.log.info(`Gadget Drone v${env.pkg.version} shutting down`);
|
||||
this.isShuttingDown = true;
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
delete this.socket;
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformService.unregister();
|
||||
} catch (error) {
|
||||
this.log.error("failed to unregister drone from Gadget Code platform", {
|
||||
error,
|
||||
});
|
||||
// fall through
|
||||
}
|
||||
|
||||
await this.stopServices();
|
||||
|
||||
return 0;
|
||||
@ -191,6 +200,10 @@ class GadgetDrone extends GadgetProcess {
|
||||
this.onRequestWorkspaceMode.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,
|
||||
});
|
||||
}
|
||||
|
||||
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 () => {
|
||||
|
||||
@ -47,3 +47,7 @@ export type CrashRecoveryResponseMessage = (data: {
|
||||
action: "discard" | "retry";
|
||||
retryDelay?: number;
|
||||
}) => void;
|
||||
|
||||
export type RequestTerminationMessage = (
|
||||
cb: (success: boolean) => void,
|
||||
) => void;
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
WorkOrderCompleteMessage,
|
||||
RequestCrashRecoveryMessage,
|
||||
CrashRecoveryResponseMessage,
|
||||
RequestTerminationMessage,
|
||||
} from "./drone.ts";
|
||||
import {
|
||||
RequestSessionLockMessage,
|
||||
@ -53,6 +54,7 @@ export interface ClientToServerEvents {
|
||||
toolCall: ToolCallMessage;
|
||||
workOrderComplete: WorkOrderCompleteMessage;
|
||||
requestCrashRecovery: RequestCrashRecoveryMessage;
|
||||
requestTermination: RequestTerminationMessage;
|
||||
}
|
||||
|
||||
export interface ServerToClientEvents {
|
||||
@ -64,6 +66,7 @@ export interface ServerToClientEvents {
|
||||
requestWorkspaceMode: RequestWorkspaceModeMessage;
|
||||
processWorkOrder: ProcessWorkOrderMessage;
|
||||
crashRecoveryResponse: CrashRecoveryResponseMessage;
|
||||
requestTermination: RequestTerminationMessage;
|
||||
|
||||
/*
|
||||
* gadget-code:web => gadget-code:ide
|
||||
|
||||
Loading…
Reference in New Issue
Block a user