Skip to content

Commit 4bf61b0

Browse files
committed
feat: Improve error handling and user feedback
- Add toast notifications for API failures (entity details, children loading) - Display fallback UI panel when entity details fail to load instead of empty state - Infer entity type from path structure for better error context - Add explicit error property to SovdEntityDetails interface - Improve error messages: include details for HTTP errors, friendly JSON validation - Remove unnecessary console.error calls and try/catch wrappers - Fix duplicate toast on auto-connect failure (React Strict Mode) - Add request timeout handling with user-friendly messages
1 parent 04b117f commit 4bf61b0

File tree

5 files changed

+109
-104
lines changed

5 files changed

+109
-104
lines changed

src/App.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from 'react';
1+
import { useState, useEffect, useRef } from 'react';
22
import { useShallow } from 'zustand/shallow';
33
import { ToastContainer, toast } from 'react-toastify';
44
import 'react-toastify/dist/ReactToastify.css';
@@ -18,17 +18,18 @@ function App() {
1818
);
1919

2020
const [showConnectionDialog, setShowConnectionDialog] = useState(false);
21+
const autoConnectAttempted = useRef(false);
2122

2223
// Auto-connect on mount if we have a stored URL
2324
useEffect(() => {
24-
if (serverUrl && !isConnected) {
25-
Promise.resolve(connect(serverUrl, baseEndpoint))
26-
.catch((err) => {
27-
toast.error(
28-
`Auto-connect failed: ${err?.message || 'Please check your server settings.'}`
29-
);
25+
if (serverUrl && !isConnected && !autoConnectAttempted.current) {
26+
autoConnectAttempted.current = true;
27+
connect(serverUrl, baseEndpoint).then((success) => {
28+
if (!success) {
29+
toast.error('Auto-connect failed. Please check your server settings.');
3030
setShowConnectionDialog(true);
31-
});
31+
}
32+
});
3233
}
3334
// eslint-disable-next-line react-hooks/exhaustive-deps
3435
}, []);

src/components/EntityDetailPanel.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,14 @@ export function EntityDetailPanel({ onConnectClick }: EntityDetailPanelProps) {
5858
setPublishingTopics(prev => new Set(prev).add(topic.topic));
5959

6060
try {
61-
const data = JSON.parse(inputData);
61+
let data: unknown;
62+
try {
63+
data = JSON.parse(inputData);
64+
} catch {
65+
toast.error('Invalid JSON format. Please check your message data.');
66+
return;
67+
}
68+
6269
const messageType = inferMessageType(topic.data);
6370

6471
await client.publishToComponentTopic(selectedEntity.id, topicName, {

src/lib/sovd-api.ts

Lines changed: 74 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -86,100 +86,88 @@ export class SovdApiClient {
8686
* @param path Optional path to get children of (e.g., "/devices/robot1")
8787
*/
8888
async getEntities(path?: string): Promise<SovdEntity[]> {
89-
try {
90-
// Root level -> fetch areas
91-
if (!path || path === '/') {
92-
const response = await fetchWithTimeout(this.getUrl('areas'), {
93-
method: 'GET',
94-
headers: { 'Accept': 'application/json' },
95-
});
96-
97-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
98-
const areas = await response.json();
99-
100-
return areas.map((area: { id: string }) => ({
101-
id: area.id,
102-
name: area.id,
103-
type: 'area',
104-
href: `/areas/${area.id}`,
105-
hasChildren: true
106-
}));
107-
}
108-
109-
// Area level -> fetch components
110-
// Path format: /area_id
111-
const areaId = path.replace(/^\//, '');
112-
// Check if it's a nested path (component inside area)
113-
if (areaId.includes('/')) {
114-
// We don't support children of components in this simple mapping yet
115-
return [];
116-
}
117-
118-
const response = await fetchWithTimeout(this.getUrl(`areas/${areaId}/components`), {
89+
// Root level -> fetch areas
90+
if (!path || path === '/') {
91+
const response = await fetchWithTimeout(this.getUrl('areas'), {
11992
method: 'GET',
12093
headers: { 'Accept': 'application/json' },
12194
});
12295

12396
if (!response.ok) throw new Error(`HTTP ${response.status}`);
124-
const components = await response.json();
97+
const areas = await response.json();
12598

126-
return components.map((comp: { id: string }) => ({
127-
id: comp.id,
128-
name: comp.id,
129-
type: 'component',
130-
href: `/components/${comp.id}`,
131-
hasChildren: false // Components are leaves in this view
99+
return areas.map((area: { id: string }) => ({
100+
id: area.id,
101+
name: area.id,
102+
type: 'area',
103+
href: `/areas/${area.id}`,
104+
hasChildren: true
132105
}));
106+
}
133107

134-
} catch (error) {
135-
console.error(`Failed to fetch entities from ${path || 'root'}:`, error);
136-
throw error;
108+
// Area level -> fetch components
109+
// Path format: /area_id
110+
const areaId = path.replace(/^\//, '');
111+
// Check if it's a nested path (component inside area)
112+
if (areaId.includes('/')) {
113+
// We don't support children of components in this simple mapping yet
114+
return [];
137115
}
116+
117+
const response = await fetchWithTimeout(this.getUrl(`areas/${areaId}/components`), {
118+
method: 'GET',
119+
headers: { 'Accept': 'application/json' },
120+
});
121+
122+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
123+
const components = await response.json();
124+
125+
return components.map((comp: { id: string }) => ({
126+
id: comp.id,
127+
name: comp.id,
128+
type: 'component',
129+
href: `/components/${comp.id}`,
130+
hasChildren: false // Components are leaves in this view
131+
}));
138132
}
139133

140134
/**
141135
* Get detailed information about a specific entity
142136
* @param path Entity path (e.g., "/area/component")
143137
*/
144138
async getEntityDetails(path: string): Promise<SovdEntityDetails> {
145-
try {
146-
// Path comes from the tree, e.g. "/area_id/component_id"
147-
const parts = path.split('/').filter(p => p);
148-
149-
if (parts.length === 2) {
150-
const componentId = parts[1];
151-
const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/data`), {
152-
method: 'GET',
153-
headers: { 'Accept': 'application/json' },
154-
});
155-
156-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
157-
const topicsData = await response.json() as ComponentTopic[];
158-
159-
// Return entity details with topics array
160-
return {
161-
id: componentId,
162-
name: componentId,
163-
type: 'component',
164-
href: path,
165-
topics: topicsData,
166-
};
167-
}
168-
169-
// If it's an area (length 1), maybe return basic info?
170-
// For now return empty object or basic info
139+
// Path comes from the tree, e.g. "/area_id/component_id"
140+
const parts = path.split('/').filter(p => p);
141+
142+
if (parts.length === 2) {
143+
const componentId = parts[1];
144+
const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/data`), {
145+
method: 'GET',
146+
headers: { 'Accept': 'application/json' },
147+
});
148+
149+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
150+
const topicsData = await response.json() as ComponentTopic[];
151+
152+
// Return entity details with topics array
171153
return {
172-
id: parts[0],
173-
name: parts[0],
174-
type: 'area',
154+
id: componentId,
155+
name: componentId,
156+
type: 'component',
175157
href: path,
176-
hasChildren: true
158+
topics: topicsData,
177159
};
178-
179-
} catch (error) {
180-
console.error(`Failed to fetch entity details for ${path}:`, error);
181-
throw error;
182160
}
161+
162+
// If it's an area (length 1), maybe return basic info?
163+
// For now return empty object or basic info
164+
return {
165+
id: parts[0],
166+
name: parts[0],
167+
type: 'area',
168+
href: path,
169+
hasChildren: true
170+
};
183171
}
184172

185173
/**
@@ -200,23 +188,18 @@ export class SovdApiClient {
200188
topicName: string,
201189
request: ComponentTopicPublishRequest
202190
): Promise<void> {
203-
try {
204-
const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/data/${topicName}`), {
205-
method: 'PUT',
206-
headers: {
207-
'Accept': 'application/json',
208-
'Content-Type': 'application/json',
209-
},
210-
body: JSON.stringify(request),
211-
}, 10000); // 10 second timeout for publish
212-
213-
if (!response.ok) {
214-
const errorData = await response.json().catch(() => ({}));
215-
throw new Error(errorData.error || `HTTP ${response.status}`);
216-
}
217-
} catch (error) {
218-
console.error(`Failed to publish to ${componentId}/${topicName}:`, error);
219-
throw error;
191+
const response = await fetchWithTimeout(this.getUrl(`components/${componentId}/data/${topicName}`), {
192+
method: 'PUT',
193+
headers: {
194+
'Accept': 'application/json',
195+
'Content-Type': 'application/json',
196+
},
197+
body: JSON.stringify(request),
198+
}, 10000); // 10 second timeout for publish
199+
200+
if (!response.ok) {
201+
const errorData = await response.json().catch(() => ({}));
202+
throw new Error(errorData.error || errorData.message || `Server error (HTTP ${response.status})`);
220203
}
221204
}
222205

src/lib/store.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,16 +217,28 @@ export const useAppStore = create<AppState>()(
217217
try {
218218
const details = await client.getEntityDetails(path);
219219
set({ selectedEntity: details, isLoadingDetails: false });
220-
} catch {
221-
toast.error(`Failed to load entity details for ${path}`);
220+
} catch (error) {
221+
const message = error instanceof Error ? error.message : 'Unknown error';
222+
toast.error(`Failed to load entity details for ${path}: ${message}`);
222223

223224
// Set fallback entity to allow panel to render
224-
const id = path.split('/').pop() || path;
225+
// Infer entity type from path structure
226+
const segments = path.split('/').filter(Boolean);
227+
const id = segments[segments.length - 1] || path;
228+
let inferredType: string;
229+
if (segments.length === 1) {
230+
inferredType = 'area';
231+
} else if (segments.length === 2) {
232+
inferredType = 'component';
233+
} else {
234+
inferredType = 'unknown';
235+
}
236+
225237
set({
226238
selectedEntity: {
227239
id,
228240
name: id,
229-
type: 'component',
241+
type: inferredType,
230242
href: path,
231243
topics: [],
232244
error: 'Failed to load details'

src/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface SovdEntity {
2424
export interface SovdEntityDetails extends SovdEntity {
2525
/** Topics available for this component */
2626
topics?: ComponentTopic[];
27+
/** Error message if fetching details failed */
28+
error?: string;
2729
/** Additional properties vary by entity type */
2830
[key: string]: unknown;
2931
}

0 commit comments

Comments
 (0)