Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/components/DataPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
bburda marked this conversation as resolved.
Outdated
// 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';
Comment thread
bburda marked this conversation as resolved.
Outdated

const handleCopyFromLast = () => {
if (topic.data) {
Expand Down Expand Up @@ -270,10 +278,10 @@ export function DataPanel({
)}
</div>

{/* Publish Section */}
{canPublish && (
{/* Write/Publish Section */}
{canWrite && (
<div className="border-t pt-4 space-y-2">
<span className="text-sm font-medium">Publish Message</span>
<span className="text-sm font-medium">{writeSectionLabel}</span>
<TopicPublishForm
topic={topic}
entityId={entityId}
Expand Down
10 changes: 10 additions & 0 deletions src/components/EntityDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,10 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
faults: 0,
logs: 0,
};
// Guard against late results from a previous entity overwriting the
// current entity's state. The cleanup flips `cancelled` so any setState
// calls after the entity changed become no-ops.
let cancelled = false;
const doFetchResourceCounts = async () => {
// Mark topicsData as "not loaded yet for the current entity" so the
Comment thread
bburda marked this conversation as resolved.
// Data tab renders a skeleton instead of an empty-state flash while
Expand Down Expand Up @@ -463,20 +467,26 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
fetchEntityData(entityType, entityId).catch(() => [] as ComponentTopic[]),
]);

if (cancelled) return;

// Store the fetched data for the Data tab
const fetchedData = Array.isArray(dataRes) ? dataRes : [];
setTopicsData(fetchedData);

// 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([]);
}
};

doFetchResourceCounts();
return () => {
cancelled = true;
};
}, [selectedEntity, prefetchResourceCounts, fetchEntityData]);

const handleCopyEntity = async () => {
Expand Down
21 changes: 18 additions & 3 deletions src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2151,13 +2151,28 @@ export const useAppStore = create<AppState>()(
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 = <T>(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,
};
},

Expand Down
81 changes: 81 additions & 0 deletions src/lib/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,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 never);
Comment thread
bburda marked this conversation as resolved.
Outdated
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 never);
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 never);
expect(result.severity).toBe('warning');
});
});

it('includes occurrence metadata in parameters', () => {
Expand Down Expand Up @@ -558,6 +615,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();
});
});
});

Expand Down
47 changes: 33 additions & 14 deletions src/lib/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,21 @@ export function unwrapItems<T>(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[];
Expand All @@ -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() || '';
Comment thread
bburda marked this conversation as resolved.
Outdated
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';
Expand All @@ -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',
Comment thread
bburda marked this conversation as resolved.
Outdated
message: apiFault.description ?? apiFault.fault_name ?? '',
severity,
status,
timestamp: (() => {
Expand Down Expand Up @@ -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 = xm?.access?.toLowerCase();
Comment thread
bburda marked this conversation as resolved.
Outdated
const access: 'read' | 'write' | 'readwrite' | undefined =
rawAccess === 'read' || rawAccess === 'write' || rawAccess === 'readwrite' ? rawAccess : undefined;
return {
topic: topicName,
timestamp: Date.now(),
Expand All @@ -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,
};
});
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

/**
Expand Down
Loading