/
/
/
1import { useState, useCallback } from 'react';
2import { RecordingControls } from './components/RecordingControls';
3import { TopicSelector } from './components/TopicSelector';
4import { StatusDisplay } from './components/StatusDisplay';
5import { BatchActions } from './components/BatchActions';
6import { useRecording } from './hooks/useRecording';
7import type { RecordingConfig } from './api/types';
8import { ConnectionStatus } from './api/types';
9
10function App() {
11 const {
12 status,
13 topics,
14 recordings,
15 connectionStatus,
16 lastError,
17 isLoading,
18 startRecording,
19 stopRecording,
20 refreshTopics,
21 refreshRecordings,
22 deleteRecording,
23 // Batch operations
24 batchDownloadRecordings,
25 batchDeleteRecordings,
26 downloadSingleRecording,
27 // Selection state
28 selectionState,
29 toggleSelection,
30 toggleSelectAll,
31 clearSelection,
32 } = useRecording();
33
34 const [selectedTopics, setSelectedTopics] = useState<string[]>([]);
35
36 const handleStartRecording = useCallback(async (config: RecordingConfig) => {
37 // Update config with selected topics
38 const configWithTopics: RecordingConfig = {
39 ...config,
40 topics: selectedTopics,
41 };
42
43 if (selectedTopics.length === 0) {
44 throw new Error('Please select at least one topic to record');
45 }
46
47 await startRecording(configWithTopics);
48 }, [selectedTopics, startRecording]);
49
50 const handleStopRecording = useCallback(async () => {
51 await stopRecording();
52 // Optionally refresh recordings list after stopping
53 setTimeout(() => {
54 refreshRecordings();
55 }, 1000);
56 }, [stopRecording, refreshRecordings]);
57
58 const handleBatchDownload = useCallback(async () => {
59 try {
60 await batchDownloadRecordings(selectionState.selectedFilenames);
61 } catch (error) {
62 console.error('Batch download failed:', error);
63 }
64 }, [batchDownloadRecordings, selectionState.selectedFilenames]);
65
66 const handleBatchDelete = useCallback(async () => {
67 try {
68 await batchDeleteRecordings(selectionState.selectedFilenames);
69 } catch (error) {
70 console.error('Batch delete failed:', error);
71 }
72 }, [batchDeleteRecordings, selectionState.selectedFilenames]);
73
74 const handleSingleDownload = useCallback(async (filename: string) => {
75 try {
76 await downloadSingleRecording(filename);
77 } catch (error) {
78 console.error('Download failed:', error);
79 }
80 }, [downloadSingleRecording]);
81
82 const isConnected = connectionStatus === ConnectionStatus.CONNECTED;
83
84 return (
85 <div className="min-h-screen bg-gray-100">
86 {/* Header */}
87 <header className="bg-white shadow-sm border-b border-gray-200">
88 <div className="max-w-7xl mx-auto px-4 py-6">
89 <div className="flex items-center justify-between">
90 <div>
91 <h1 className="text-3xl font-bold text-gray-900">
92 ROS2 Bag Recorder
93 </h1>
94 <p className="text-gray-600 mt-1">
95 Web interface for recording ROS2 bag files
96 </p>
97 </div>
98
99 <div className="flex items-center space-x-4">
100 <button
101 onClick={refreshTopics}
102 disabled={isLoading}
103 className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
104 >
105 {isLoading ? 'Refreshing...' : 'Refresh Topics'}
106 </button>
107
108 <button
109 onClick={refreshRecordings}
110 disabled={isLoading}
111 className="px-4 py-2 text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
112 >
113 Refresh Recordings
114 </button>
115 </div>
116 </div>
117 </div>
118 </header>
119
120 {/* Main Content */}
121 <main className="max-w-7xl mx-auto px-4 py-8">
122 {/* Loading Overlay */}
123 {isLoading && (
124 <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
125 <div className="bg-white rounded-lg p-6 flex items-center space-x-4">
126 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
127 <span className="text-gray-700">Loading...</span>
128 </div>
129 </div>
130 )}
131
132 <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
133 {/* Left Column - Controls and Status */}
134 <div className="lg:col-span-1 space-y-6">
135 <StatusDisplay
136 status={status}
137 connectionStatus={connectionStatus}
138 lastError={lastError}
139 />
140
141 <RecordingControls
142 status={status}
143 isConnected={isConnected}
144 onStartRecording={handleStartRecording}
145 onStopRecording={handleStopRecording}
146 />
147 </div>
148
149 {/* Right Column - Topic Selection */}
150 <div className="lg:col-span-2">
151 <TopicSelector
152 topics={topics}
153 selectedTopics={selectedTopics}
154 onTopicsChange={setSelectedTopics}
155 disabled={status.is_recording || !isConnected}
156 />
157 </div>
158 </div>
159
160 {/* Recordings Section */}
161 {recordings.length > 0 && (
162 <div className="mt-8">
163 <div className="bg-white rounded-lg shadow-md p-6">
164 <h2 className="text-xl font-semibold text-gray-800 mb-4">
165 Recent Recordings ({recordings.length})
166 </h2>
167
168 {/* Batch Actions */}
169 <BatchActions
170 selectedFilenames={selectionState.selectedFilenames}
171 selectedCount={selectionState.selectedFilenames.length}
172 totalCount={recordings.length}
173 onDownloadSelected={handleBatchDownload}
174 onDeleteSelected={handleBatchDelete}
175 onClearSelection={clearSelection}
176 isLoading={isLoading}
177 />
178
179 <div className="overflow-x-auto">
180 <table className="w-full text-sm">
181 <thead>
182 <tr className="border-b border-gray-200">
183 <th className="w-12 py-3 px-4">
184 <input
185 type="checkbox"
186 checked={selectionState.isAllSelected && recordings.length > 0}
187 onChange={toggleSelectAll}
188 className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
189 disabled={isLoading}
190 />
191 </th>
192 <th className="text-left py-3 px-4 font-medium text-gray-700">Name</th>
193 <th className="text-left py-3 px-4 font-medium text-gray-700">Size</th>
194 <th className="text-left py-3 px-4 font-medium text-gray-700">Path</th>
195 <th className="text-left py-3 px-4 font-medium text-gray-700">Actions</th>
196 </tr>
197 </thead>
198 <tbody>
199 {recordings.map((recording, index) => {
200 const isSelected = selectionState.selectedFilenames.includes(recording.name);
201 return (
202 <tr key={`${recording.name}-${index}`} className={`border-b border-gray-100 ${isSelected ? 'bg-blue-50' : 'hover:bg-gray-50'}`}>
203 <td className="py-3 px-4">
204 <input
205 type="checkbox"
206 checked={isSelected}
207 onChange={() => toggleSelection(recording.name)}
208 className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
209 disabled={isLoading}
210 />
211 </td>
212 <td className="py-3 px-4 font-mono text-gray-900">
213 {recording.name}
214 </td>
215 <td className="py-3 px-4 text-gray-600">
216 {(recording.size / (1024 * 1024)).toFixed(1)} MB
217 </td>
218 <td className="py-3 px-4 text-gray-600 font-mono text-xs">
219 {recording.path}
220 </td>
221 <td className="py-3 px-4">
222 <div className="flex items-center space-x-2">
223 <button
224 onClick={() => handleSingleDownload(recording.name)}
225 className="text-blue-600 hover:text-blue-800 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
226 disabled={isLoading}
227 >
228 Download
229 </button>
230 <button
231 className="text-red-600 hover:text-red-800 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
232 disabled={isLoading}
233 onClick={async () => {
234 if (window.confirm(`Are you sure you want to delete recording "${recording.name}"?`)) {
235 try {
236 await deleteRecording(recording.name);
237 } catch (error) {
238 console.error('Failed to delete recording:', error);
239 }
240 }
241 }}
242 >
243 Delete
244 </button>
245 </div>
246 </td>
247 </tr>
248 );
249 })}
250 </tbody>
251 </table>
252 </div>
253 </div>
254 </div>
255 )}
256
257 {/* Empty State */}
258 {topics.length === 0 && !isLoading && (
259 <div className="mt-8 text-center py-12">
260 <div className="text-gray-400 mb-4">
261 <svg className="mx-auto h-16 w-16" fill="currentColor" viewBox="0 0 20 20">
262 <path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
263 </svg>
264 </div>
265 <h3 className="text-lg font-medium text-gray-900 mb-2">No ROS2 topics found</h3>
266 <p className="text-gray-500 mb-4">
267 Make sure ROS2 nodes are running and publishing topics.
268 </p>
269 <button
270 onClick={refreshTopics}
271 className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
272 >
273 Refresh Topics
274 </button>
275 </div>
276 )}
277 </main>
278
279 {/* Footer */}
280 <footer className="bg-white border-t border-gray-200 mt-16">
281 <div className="max-w-7xl mx-auto px-4 py-6">
282 <div className="text-center text-sm text-gray-500">
283 ROS2 Bag Recorder Web Interface ⢠Built with React and TypeScript
284 </div>
285 </div>
286 </footer>
287 </div>
288 );
289}
290
291export default App;