diff --git a/src/components/DataPanel.tsx b/src/components/DataPanel.tsx
index 584eebc..7f5b4dd 100644
--- a/src/components/DataPanel.tsx
+++ b/src/components/DataPanel.tsx
@@ -201,7 +201,15 @@ export function DataPanel({
const isConnected = useAppStore((state) => state.isConnected);
const hasData = topic.status === 'data' && topic.data !== null && topic.data !== undefined;
- const canPublish = isConnected && !!(topic.type || topic.type_info || topic.data);
+ // `access` is the explicit per-item write capability; when present it
+ // overrides the legacy "any typed topic is publishable" heuristic so a
+ // read-only data item never surfaces a write form.
+ const canWrite = isConnected && topic.access !== 'read' && !!(topic.type || topic.type_info || topic.data);
+ // Use "Write Value" when the gateway told us this is a writable scalar
+ // (access === 'write' / 'readwrite'); fall back to "Publish Message" for
+ // streaming topics where the operation really is a publish.
+ const writeSectionLabel =
+ topic.access === 'write' || topic.access === 'readwrite' ? 'Write Value' : 'Publish Message';
const handleCopyFromLast = () => {
if (topic.data) {
@@ -270,10 +278,10 @@ export function DataPanel({
)}
- {/* Publish Section */}
- {canPublish && (
+ {/* Write/Publish Section */}
+ {canWrite && (
-
Publish Message
+
{writeSectionLabel}
{
// Mark topicsData as "not loaded yet for the current entity" so the
// Data tab renders a skeleton instead of an empty-state flash while
@@ -459,10 +465,12 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
try {
// Fetch resource counts and data in parallel
const [counts, dataRes] = await Promise.all([
- prefetchResourceCounts(entityType, entityId),
- fetchEntityData(entityType, entityId).catch(() => [] as ComponentTopic[]),
+ prefetchResourceCounts(entityType, entityId, controller.signal),
+ fetchEntityData(entityType, entityId, controller.signal).catch(() => [] as ComponentTopic[]),
]);
+ if (cancelled) return;
+
// Store the fetched data for the Data tab
const fetchedData = Array.isArray(dataRes) ? dataRes : [];
setTopicsData(fetchedData);
@@ -470,6 +478,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
// Use the already-fetched data length instead of a separate request
setResourceCounts({ ...counts, data: fetchedData.length, logs: 0 });
} catch {
+ if (cancelled) return;
// On unexpected failure fall back to "loaded empty" so the UI
// doesn't get stuck showing the skeleton forever.
setTopicsData([]);
@@ -477,6 +486,10 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
};
doFetchResourceCounts();
+ return () => {
+ cancelled = true;
+ controller.abort();
+ };
}, [selectedEntity, prefetchResourceCounts, fetchEntityData]);
const handleCopyEntity = async () => {
diff --git a/src/lib/store.ts b/src/lib/store.ts
index cc8a462..54f8264 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -213,7 +213,8 @@ export interface AppState {
getFunctionHosts: (functionId: string) => Promise;
prefetchResourceCounts: (
entityType: SovdResourceEntityType,
- entityId: string
+ entityId: string,
+ signal?: AbortSignal
) => Promise<{ data: number; operations: number; configurations: number; faults: number }>;
}
@@ -2132,7 +2133,11 @@ export const useAppStore = create()(
return data ? unwrapItems(data) : [];
},
- prefetchResourceCounts: async (entityType: SovdResourceEntityType, entityId: string) => {
+ prefetchResourceCounts: async (
+ entityType: SovdResourceEntityType,
+ entityId: string,
+ signal?: AbortSignal
+ ) => {
const { client } = get();
if (!client) return { data: 0, operations: 0, configurations: 0, faults: 0 };
@@ -2140,24 +2145,42 @@ export const useAppStore = create()(
// The caller (EntityDetailPanel) already fetches entity data via fetchEntityData
// and overrides counts.data with the result length.
const [opsRes, configRes, faultsRes] = await Promise.all([
- getEntityOperations(client, entityType, entityId).catch(() => ({
+ getEntityOperations(client, entityType, entityId, signal).catch(() => ({
data: undefined,
error: undefined,
})),
- getEntityConfigurations(client, entityType, entityId).catch(() => ({
+ getEntityConfigurations(client, entityType, entityId, signal).catch(() => ({
+ data: undefined,
+ error: undefined,
+ })),
+ getEntityFaults(client, entityType, entityId, signal).catch(() => ({
data: undefined,
error: undefined,
})),
- getEntityFaults(client, entityType, entityId).catch(() => ({ data: undefined, error: undefined })),
]);
+ // Isolate each transform call: a malformed payload from one
+ // resource (e.g. UDS DTC faults with non-canonical schema)
+ // must not crash the others or the caller's Promise.all.
+ const safeCount = (fn: () => T, fallback: T): T => {
+ try {
+ return fn();
+ } catch {
+ return fallback;
+ }
+ };
return {
data: 0,
- operations: opsRes.data ? unwrapItems(opsRes.data).length : 0,
+ operations: opsRes.data ? safeCount(() => unwrapItems(opsRes.data).length, 0) : 0,
configurations: configRes.data
- ? transformConfigurationsResponse(configRes.data, entityId).parameters.length
+ ? safeCount(
+ () => transformConfigurationsResponse(configRes.data, entityId).parameters.length,
+ 0
+ )
+ : 0,
+ faults: faultsRes.data
+ ? safeCount(() => transformFaultsResponse(faultsRes.data).items.length, 0)
: 0,
- faults: faultsRes.data ? transformFaultsResponse(faultsRes.data).items.length : 0,
};
},
diff --git a/src/lib/transforms.test.ts b/src/lib/transforms.test.ts
index 1aa86d5..3fde215 100644
--- a/src/lib/transforms.test.ts
+++ b/src/lib/transforms.test.ts
@@ -21,6 +21,7 @@ import {
transformDataResponse,
transformConfigurationsResponse,
transformBulkDataDescriptor,
+ type RawFaultItem,
} from './transforms';
// =============================================================================
@@ -270,6 +271,63 @@ describe('transformFault', () => {
const result = transformFault(makeFaultInput({ status: 'UNKNOWN_STATUS' }));
expect(result.status).toBe('active');
});
+
+ it('reads aggregatedStatus when status is an object', () => {
+ const result = transformFault(makeFaultInput({ status: { aggregatedStatus: 'active', extra: '1' } }));
+ expect(result.status).toBe('active');
+ });
+
+ it('maps object aggregatedStatus "passive" to pending', () => {
+ const result = transformFault(makeFaultInput({ status: { aggregatedStatus: 'passive' } }));
+ expect(result.status).toBe('pending');
+ });
+
+ it('does not throw when status is null', () => {
+ const result = transformFault(makeFaultInput({ status: null }));
+ expect(result.status).toBe('active');
+ });
+
+ it('does not throw when status is missing', () => {
+ const result = transformFault(makeFaultInput({ status: undefined }));
+ expect(result.status).toBe('active');
+ });
+ });
+
+ describe('field aliases', () => {
+ it('accepts code as a fallback for fault_code', () => {
+ const result = transformFault({
+ code: 'ALT_CODE',
+ description: 'x',
+ severity: 1,
+ severity_label: 'warn',
+ status: 'CONFIRMED',
+ first_occurred: 1700000000,
+ } as unknown as RawFaultItem);
+ expect(result.code).toBe('ALT_CODE');
+ });
+
+ it('accepts fault_name as a fallback for description', () => {
+ const result = transformFault({
+ fault_code: 'F1',
+ fault_name: 'Alternative description',
+ severity: 1,
+ severity_label: 'warn',
+ status: 'CONFIRMED',
+ first_occurred: 1700000000,
+ } as unknown as RawFaultItem);
+ expect(result.message).toBe('Alternative description');
+ });
+
+ it('does not throw when severity is undefined', () => {
+ const result = transformFault({
+ fault_code: 'F1',
+ description: 'x',
+ severity_label: 'warn',
+ status: 'CONFIRMED',
+ first_occurred: 1700000000,
+ } as unknown as RawFaultItem);
+ expect(result.severity).toBe('warning');
+ });
});
it('includes occurrence metadata in parameters', () => {
@@ -558,6 +616,30 @@ describe('transformDataResponse', () => {
const result = transformDataResponse({ items: [raw] });
expect(result[0]?.uniqueKey).toBe('x:publish');
});
+
+ it('passes through access="read"', () => {
+ const raw = { id: 'x', name: 'x', 'x-medkit': { access: 'read' } };
+ const result = transformDataResponse({ items: [raw] });
+ expect(result[0]?.access).toBe('read');
+ });
+
+ it('passes through access="readwrite"', () => {
+ const raw = { id: 'x', name: 'x', 'x-medkit': { access: 'readwrite' } };
+ const result = transformDataResponse({ items: [raw] });
+ expect(result[0]?.access).toBe('readwrite');
+ });
+
+ it('lowercases access for case-insensitive matching', () => {
+ const raw = { id: 'x', name: 'x', 'x-medkit': { access: 'WRITE' } };
+ const result = transformDataResponse({ items: [raw] });
+ expect(result[0]?.access).toBe('write');
+ });
+
+ it('drops unrecognised access values', () => {
+ const raw = { id: 'x', name: 'x', 'x-medkit': { access: 'execute' } };
+ const result = transformDataResponse({ items: [raw] });
+ expect(result[0]?.access).toBeUndefined();
+ });
});
});
diff --git a/src/lib/transforms.ts b/src/lib/transforms.ts
index d8ff805..2e37777 100644
--- a/src/lib/transforms.ts
+++ b/src/lib/transforms.ts
@@ -59,14 +59,21 @@ export function unwrapItems(response: unknown): T[] {
* Raw fault item shape returned by the gateway faults endpoints.
*/
export interface RawFaultItem {
- fault_code: string;
- description: string;
- severity: number;
- severity_label: string;
- status: string;
+ /** Canonical SOVD identifier. `code` accepted as a fallback for backends
+ * that have not yet aligned on the SOVD field name. */
+ fault_code?: string;
+ code?: string;
+ /** Human-readable description. `fault_name` accepted as a fallback. */
+ description?: string;
+ fault_name?: string;
+ severity?: number;
+ severity_label?: string;
+ /** Canonical value is a string. An object form with `aggregatedStatus`
+ * is also accepted to keep the UI from crashing on payload drift. */
+ status?: string | { aggregatedStatus?: string; [key: string]: unknown } | null;
/** Accepted as unix seconds (number), ISO 8601 string, or missing/invalid;
* `transformFault` normalises all of these to an ISO timestamp. */
- first_occurred: number | string | null | undefined;
+ first_occurred?: number | string | null;
last_occurred?: number | string | null;
occurrence_count?: number;
reporting_sources?: string[];
@@ -91,21 +98,29 @@ export function transformFault(apiFault: RawFaultItem): Fault {
// Map severity number/label to FaultSeverity.
// Label check takes priority over numeric value; critical is checked first.
let severity: FaultSeverity = 'info';
+ const severityNum = typeof apiFault.severity === 'number' ? apiFault.severity : 0;
const label = apiFault.severity_label?.toLowerCase() || '';
- if (label === 'critical' || apiFault.severity >= 3) {
+ if (label === 'critical' || severityNum >= 3) {
severity = 'critical';
- } else if (label === 'error' || apiFault.severity === 2) {
+ } else if (label === 'error' || severityNum === 2) {
severity = 'error';
- } else if (label === 'warn' || label === 'warning' || apiFault.severity === 1) {
+ } else if (label === 'warn' || label === 'warning' || severityNum === 1) {
severity = 'warning';
}
- // Map API status string to FaultStatusValue.
+ // Map API status to FaultStatusValue. Accept either a string (canonical SOVD)
+ // or an object with an `aggregatedStatus` field (UDS DTC-style backends).
+ let apiStatus = '';
+ if (typeof apiFault.status === 'string') {
+ apiStatus = apiFault.status.toLowerCase();
+ } else if (apiFault.status && typeof apiFault.status === 'object') {
+ const agg = (apiFault.status as { aggregatedStatus?: unknown }).aggregatedStatus;
+ if (typeof agg === 'string') apiStatus = agg.toLowerCase();
+ }
let status: FaultStatusValue = 'active';
- const apiStatus = apiFault.status?.toLowerCase() || '';
if (apiStatus === 'confirmed' || apiStatus === 'active') {
status = 'active';
- } else if (apiStatus === 'pending' || apiStatus === 'prefailed') {
+ } else if (apiStatus === 'pending' || apiStatus === 'prefailed' || apiStatus === 'passive') {
status = 'pending';
} else if (apiStatus === 'cleared' || apiStatus === 'resolved') {
status = 'cleared';
@@ -125,8 +140,8 @@ export function transformFault(apiFault: RawFaultItem): Fault {
const entity_type = apiFault.entity_type || 'app';
return {
- code: apiFault.fault_code,
- message: apiFault.description,
+ code: apiFault.fault_code ?? apiFault.code ?? 'unknown',
+ message: apiFault.description ?? apiFault.fault_name ?? '',
severity,
status,
timestamp: (() => {
@@ -328,6 +343,9 @@ export function transformDataResponse(rawData: unknown): ComponentTopic[] {
// `direction` above.
const typeLabel = xm?.ros2?.type ?? xm?.type;
const hasValue = item.value !== undefined;
+ const rawAccess = typeof xm?.access === 'string' ? xm.access.toLowerCase() : undefined;
+ const access: 'read' | 'write' | 'readwrite' | undefined =
+ rawAccess === 'read' || rawAccess === 'write' || rawAccess === 'readwrite' ? rawAccess : undefined;
return {
topic: topicName,
timestamp: Date.now(),
@@ -344,6 +362,7 @@ export function transformDataResponse(rawData: unknown): ComponentTopic[] {
isPublisher: direction === 'publish' || direction === 'both' || direction === 'output',
isSubscriber: direction === 'subscribe' || direction === 'both' || direction === 'input',
uniqueKey: direction ? `${topicName}:${direction}` : topicName,
+ access,
};
});
}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index ab28f78..b874fba 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -214,6 +214,10 @@ export interface ComponentTopic {
isSubscriber?: boolean;
/** Unique key combining topic and direction for React key */
uniqueKey?: string;
+ /** Access mode from `x-medkit.access`. `'read'` hides the write section,
+ * `'write'` / `'readwrite'` enable it. Absent means "no constraint" (the
+ * legacy ROS 2 behaviour where any topic with a known type may publish). */
+ access?: 'read' | 'write' | 'readwrite';
}
/**