Skip to content

Commit d01595a

Browse files
committed
feat: add operations and configurations panels with entity tree restructure
- Add ConfigurationPanel for viewing/editing ROS 2 parameters - Add OperationsPanel for invoking services and actions - Add ActionStatusPanel with auto-refresh for monitoring action goals - Restructure entity tree with virtual folders (data/operations/configurations) - Add lazy loading for virtual folder contents - Add empty state indicator for folders without content - Add detail views for service, action, and parameter entities - Extend sovd-api.ts with configurations and operations endpoints - Add new types for Parameter, Operation, ActionGoalStatus
1 parent c7a5528 commit d01595a

11 files changed

Lines changed: 3009 additions & 299 deletions
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { useEffect, useCallback } from 'react';
2+
import { useShallow } from 'zustand/shallow';
3+
import { Activity, RefreshCw, XCircle, CheckCircle, AlertCircle, Clock, Loader2, Navigation } from 'lucide-react';
4+
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
5+
import { Button } from '@/components/ui/button';
6+
import { Badge } from '@/components/ui/badge';
7+
import { useAppStore, type AppState } from '@/lib/store';
8+
import type { ActionGoalStatusValue } from '@/lib/types';
9+
10+
interface ActionStatusPanelProps {
11+
componentId: string;
12+
operationName: string;
13+
goalId: string;
14+
}
15+
16+
/**
17+
* Get status badge variant and icon
18+
*/
19+
function getStatusStyle(status: ActionGoalStatusValue): {
20+
variant: 'default' | 'secondary' | 'destructive' | 'outline';
21+
icon: typeof CheckCircle;
22+
color: string;
23+
bgColor: string;
24+
} {
25+
switch (status) {
26+
case 'accepted':
27+
return { variant: 'outline', icon: Clock, color: 'text-blue-500', bgColor: 'bg-blue-500/10' };
28+
case 'executing':
29+
return { variant: 'default', icon: Activity, color: 'text-blue-500', bgColor: 'bg-blue-500/10' };
30+
case 'canceling':
31+
return { variant: 'secondary', icon: XCircle, color: 'text-yellow-500', bgColor: 'bg-yellow-500/10' };
32+
case 'succeeded':
33+
return { variant: 'default', icon: CheckCircle, color: 'text-green-500', bgColor: 'bg-green-500/10' };
34+
case 'canceled':
35+
return { variant: 'secondary', icon: XCircle, color: 'text-gray-500', bgColor: 'bg-gray-500/10' };
36+
case 'aborted':
37+
return { variant: 'destructive', icon: AlertCircle, color: 'text-red-500', bgColor: 'bg-red-500/10' };
38+
default:
39+
return { variant: 'outline', icon: Clock, color: 'text-muted-foreground', bgColor: 'bg-muted' };
40+
}
41+
}
42+
43+
/**
44+
* Check if status is terminal (no more updates expected)
45+
*/
46+
function isTerminalStatus(status: ActionGoalStatusValue): boolean {
47+
return ['succeeded', 'canceled', 'aborted'].includes(status);
48+
}
49+
50+
/**
51+
* Check if status is active (action is in progress)
52+
*/
53+
function isActiveStatus(status: ActionGoalStatusValue): boolean {
54+
return ['accepted', 'executing', 'canceling'].includes(status);
55+
}
56+
57+
export function ActionStatusPanel({ componentId, operationName, goalId }: ActionStatusPanelProps) {
58+
const {
59+
activeGoals,
60+
autoRefreshGoals,
61+
refreshActionStatus,
62+
cancelActionGoal,
63+
setAutoRefreshGoals,
64+
} = useAppStore(
65+
useShallow((state: AppState) => ({
66+
activeGoals: state.activeGoals,
67+
autoRefreshGoals: state.autoRefreshGoals,
68+
refreshActionStatus: state.refreshActionStatus,
69+
cancelActionGoal: state.cancelActionGoal,
70+
setAutoRefreshGoals: state.setAutoRefreshGoals,
71+
}))
72+
);
73+
74+
const goalStatus = activeGoals.get(goalId);
75+
const statusStyle = goalStatus ? getStatusStyle(goalStatus.status) : null;
76+
const StatusIcon = statusStyle?.icon || Clock;
77+
const isTerminal = goalStatus ? isTerminalStatus(goalStatus.status) : false;
78+
const isActive = goalStatus ? isActiveStatus(goalStatus.status) : false;
79+
const canCancel = goalStatus && ['accepted', 'executing'].includes(goalStatus.status);
80+
81+
// Manual refresh
82+
const handleRefresh = useCallback(() => {
83+
refreshActionStatus(componentId, operationName, goalId);
84+
}, [componentId, operationName, goalId, refreshActionStatus]);
85+
86+
// Cancel action
87+
const handleCancel = useCallback(async () => {
88+
await cancelActionGoal(componentId, operationName, goalId);
89+
}, [componentId, operationName, goalId, cancelActionGoal]);
90+
91+
// Auto-refresh effect
92+
useEffect(() => {
93+
if (!autoRefreshGoals || isTerminal) return;
94+
95+
const interval = setInterval(() => {
96+
refreshActionStatus(componentId, operationName, goalId);
97+
}, 1000); // Refresh every second
98+
99+
return () => clearInterval(interval);
100+
}, [autoRefreshGoals, isTerminal, componentId, operationName, goalId, refreshActionStatus]);
101+
102+
// Initial fetch
103+
useEffect(() => {
104+
if (!goalStatus) {
105+
refreshActionStatus(componentId, operationName, goalId);
106+
}
107+
}, [goalId, goalStatus, componentId, operationName, refreshActionStatus]);
108+
109+
if (!goalStatus) {
110+
return (
111+
<div className="flex items-center justify-center p-4">
112+
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
113+
</div>
114+
);
115+
}
116+
117+
return (
118+
<Card className={`${statusStyle?.bgColor} border-${statusStyle?.color?.replace('text-', '')}/30`}>
119+
<CardHeader className="py-3 px-4">
120+
<div className="flex items-center justify-between">
121+
<div className="flex items-center gap-2">
122+
{isActive ? (
123+
<div className="relative">
124+
<StatusIcon className={`w-4 h-4 ${statusStyle?.color} ${goalStatus.status === 'executing' ? 'animate-pulse' : ''}`} />
125+
{goalStatus.status === 'executing' && (
126+
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-blue-500 rounded-full animate-ping" />
127+
)}
128+
</div>
129+
) : (
130+
<StatusIcon className={`w-4 h-4 ${statusStyle?.color}`} />
131+
)}
132+
<CardTitle className="text-sm">Action Status</CardTitle>
133+
<Badge variant={statusStyle?.variant} className={isActive ? 'animate-pulse' : ''}>
134+
{goalStatus.status}
135+
</Badge>
136+
</div>
137+
138+
<div className="flex items-center gap-2">
139+
{/* Auto-refresh checkbox */}
140+
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
141+
<input
142+
type="checkbox"
143+
checked={autoRefreshGoals}
144+
onChange={(e) => setAutoRefreshGoals(e.target.checked)}
145+
className="rounded border-muted-foreground"
146+
disabled={isTerminal}
147+
/>
148+
Auto-refresh
149+
</label>
150+
151+
{/* Manual refresh */}
152+
<Button
153+
variant="ghost"
154+
size="sm"
155+
onClick={handleRefresh}
156+
disabled={isTerminal}
157+
className="h-7 w-7 p-0"
158+
>
159+
<RefreshCw className={`w-3.5 h-3.5 ${isActive && autoRefreshGoals ? 'animate-spin' : ''}`} />
160+
</Button>
161+
162+
{/* Cancel button */}
163+
{canCancel && (
164+
<Button
165+
variant="destructive"
166+
size="sm"
167+
onClick={handleCancel}
168+
className="h-7"
169+
>
170+
<XCircle className="w-3.5 h-3.5 mr-1" />
171+
Cancel
172+
</Button>
173+
)}
174+
</div>
175+
</div>
176+
</CardHeader>
177+
178+
<CardContent className="py-2 px-4 space-y-3">
179+
{/* Progress bar for active actions */}
180+
{isActive && (
181+
<div className="space-y-1">
182+
<div className="flex items-center gap-2">
183+
<Navigation className="w-3.5 h-3.5 text-blue-500 animate-bounce" />
184+
<span className="text-xs text-muted-foreground">
185+
{goalStatus.status === 'accepted' && 'Waiting to start...'}
186+
{goalStatus.status === 'executing' && 'Action in progress...'}
187+
{goalStatus.status === 'canceling' && 'Canceling...'}
188+
</span>
189+
</div>
190+
{/* Animated progress bar */}
191+
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
192+
<div className="h-full bg-blue-500 rounded-full animate-progress-indeterminate" />
193+
</div>
194+
</div>
195+
)}
196+
197+
{/* Goal ID */}
198+
<div className="flex items-center gap-2 text-xs">
199+
<span className="text-muted-foreground">Goal ID:</span>
200+
<code className="bg-background/50 px-1.5 py-0.5 rounded font-mono text-xs">
201+
{goalId.slice(0, 8)}...{goalId.slice(-8)}
202+
</code>
203+
</div>
204+
205+
{/* Feedback */}
206+
{goalStatus.last_feedback !== undefined && goalStatus.last_feedback !== null && (
207+
<div>
208+
<span className="text-xs text-muted-foreground block mb-1">
209+
{isTerminal ? 'Result:' : 'Last Feedback:'}
210+
</span>
211+
<pre className="bg-background/50 p-2 rounded text-xs font-mono overflow-auto max-h-[150px]">
212+
{JSON.stringify(goalStatus.last_feedback, null, 2)}
213+
</pre>
214+
</div>
215+
)}
216+
217+
{/* Terminal state message */}
218+
{isTerminal && (
219+
<div className={`text-xs ${statusStyle?.color} flex items-center gap-1.5 font-medium`}>
220+
<StatusIcon className="w-4 h-4" />
221+
<span>
222+
{goalStatus.status === 'succeeded' && 'Action completed successfully'}
223+
{goalStatus.status === 'canceled' && 'Action was canceled'}
224+
{goalStatus.status === 'aborted' && 'Action was aborted due to an error'}
225+
</span>
226+
</div>
227+
)}
228+
</CardContent>
229+
</Card>
230+
);
231+
}

0 commit comments

Comments
 (0)