Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major - canWrite falsy-zero bug (round-2 traced end-to-end).

!!(topic.type || topic.type_info || topic.data) evaluates false when topic.data === 0 AND both type/type_info are absent:

!!(undefined || undefined || 0) === !!0 === false

This is reachable: transforms.ts:345 does hasValue = item.value !== undefined, :352 does data: hasValue ? item.value : null. So literal 0 from the gateway is preserved as topic.data: 0 (correct, since 0 is a legitimate reading). If that response also omits x-medkit.ros2.type / x-medkit.type / x-medkit.type_info (primitive scalars often do), the write form is hidden even when access === 'write' is explicitly set.

Concrete scenario: UDS odometer reading at exactly 0, RPM at idle cutoff, counter reset — anything that momentarily reads 0 and has a minimal payload.

Fix (split presence from truthiness):

const hasTypeHint = !!(topic.type || topic.type_info);
const hasValueHint = topic.data !== null && topic.data !== undefined;
const canWrite = isConnected && topic.access !== 'read' && (hasTypeHint || hasValueHint);

Or, since access is now the authoritative signal when present, trust it:

const canWrite = isConnected && (
    topic.access === 'write' ||
    topic.access === 'readwrite' ||
    (topic.access !== 'read' && (!!topic.type || !!topic.type_info))
);

Add a test: topic = { access: 'write', data: 0 } (no type) → canWrite === true.

// 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 on lines +207 to +212

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
17 changes: 15 additions & 2 deletions src/components/EntityDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,12 @@ 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 aborts in-flight requests AND
// flips `cancelled` so any setState calls after the entity changed
// become no-ops (covers transforms that ignore the abort signal).
const controller = new AbortController();
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 @@ -459,24 +465,31 @@ 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);

// 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;
controller.abort();
};
}, [selectedEntity, prefetchResourceCounts, fetchEntityData]);

const handleCopyEntity = async () => {
Expand Down
39 changes: 31 additions & 8 deletions src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ export interface AppState {
getFunctionHosts: (functionId: string) => Promise<unknown[]>;
prefetchResourceCounts: (
entityType: SovdResourceEntityType,
entityId: string
entityId: string,
signal?: AbortSignal
) => Promise<{ data: number; operations: number; configurations: number; faults: number }>;
}

Expand Down Expand Up @@ -2132,32 +2133,54 @@ export const useAppStore = create<AppState>()(
return data ? unwrapItems<unknown>(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 };

// Note: data count is NOT fetched here to avoid a duplicate request.
// 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 = <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
82 changes: 82 additions & 0 deletions src/lib/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
transformDataResponse,
transformConfigurationsResponse,
transformBulkDataDescriptor,
type RawFaultItem,
} from './transforms';

// =============================================================================
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
});

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() || '';
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',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major - 'unknown' literal collides across 5 downstream sites (round-2 traced end-to-end).

When both fault_code and code are absent AND entity_id falls back too (transforms.ts:135 source.split('/').pop() || 'unknown'), N malformed faults all get (code='unknown', entity_id='unknown'). Downstream effects:

  1. Store dedup collapse - store.ts:1912 findIndex((f) => f.code === fault.code && f.entity_id === fault.entity_id) keeps only the FIRST. N-1 faults silently dropped on fault_confirmed events.
  2. React key duplicates - 3 list sites: FaultsPanel.tsx:418, FaultsDashboard.tsx:822, FaultsDashboard.tsx:341 (FaultGroup). Console throws "Two children with same key 'unknown'", DOM reconciliation diverges.
  3. Expand-state corruption - FaultsPanel.tsx:272 + FaultsDashboard.tsx:400 use useState<Set<string>> keyed by fault.code. Clicking expand on fault Initial project structure #1 (code='unknown') also expands Initial SOVD Web UI Setup #2 and [BUG] Invalid Dockerfile paths. #3 (same key). User cannot control rows independently.

Fix options:

  • (a) Synthetic unique ID when both missing: code: apiFault.fault_code ?? apiFault.code ?? `unknown-${Date.now()}-${Math.random().toString(36).slice(2,7)}` - uniqueness only, not cryptographic.
  • (b) Leave fallback but change every list key + dedup to ${code}:${entity_id}:${timestamp} or a synthetic fault_uuid field generated at transform time.

Option (a) is a 1-line change and preserves existing dedup semantics. Option (b) is more invasive but fixes the root (single-field identity is inherently fragile).

Add regression test: transformFaultsResponse called with 3 fault items that each lack code + entity_id → result has 3 distinct .code values.

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 = 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(),
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