Skip to content
Merged
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
50 changes: 10 additions & 40 deletions src/components/FunctionsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,6 @@
import { useState, useEffect } from 'react';
import { useShallow } from 'zustand/shallow';
import {
AlertTriangle,
ChevronRight,
Cpu,
Database,
GitBranch,
Info,
Loader2,
Settings,
Users,
Zap,
} from 'lucide-react';
import { AlertTriangle, ChevronRight, Cpu, Database, GitBranch, Info, Loader2, Users, Zap } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useAppStore } from '@/lib/store';
Expand All @@ -38,10 +27,12 @@ interface TabConfig {
icon: typeof Database;
}

// Functions are capability aggregations without their own configuration surface,
// so Config is intentionally omitted to avoid 404s on `/functions/{id}/configurations`.
const FUNCTION_TABS: TabConfig[] = [
{ id: 'overview', label: 'Overview', icon: Info },
{ id: 'hosts', label: 'Hosts', icon: Cpu },
...RESOURCE_TABS,
...RESOURCE_TABS.filter((t) => t.id !== 'configurations'),
];

interface FunctionsPanelProps {
Expand All @@ -68,23 +59,13 @@ export function FunctionsPanel({ functionId, functionName, description, path, on
const [faults, setFaults] = useState<Fault[]>([]);
const [isLoading, setIsLoading] = useState(false);

const {
selectEntity,
getFunctionHosts,
fetchEntityData,
fetchEntityOperations,
fetchConfigurations,
listEntityFaults,
storeConfigurations,
} = useAppStore(
const { selectEntity, getFunctionHosts, fetchEntityData, fetchEntityOperations, listEntityFaults } = useAppStore(
useShallow((state) => ({
selectEntity: state.selectEntity,
getFunctionHosts: state.getFunctionHosts,
fetchEntityData: state.fetchEntityData,
fetchEntityOperations: state.fetchEntityOperations,
fetchConfigurations: state.fetchConfigurations,
listEntityFaults: state.listEntityFaults,
storeConfigurations: state.configurations,
}))
);

Expand All @@ -94,12 +75,12 @@ export function FunctionsPanel({ functionId, functionName, description, path, on
setIsLoading(true);

try {
// Load hosts, data, operations, configurations, and faults in parallel
const [hostsData, topicsData, opsData, , faultsData] = await Promise.all([
// Functions do not expose a configurations collection; skip that fetch
// to avoid 404s on `/functions/{id}/configurations`.
const [hostsData, topicsData, opsData, faultsData] = await Promise.all([
getFunctionHosts(functionId).catch(() => [] as unknown[]),
fetchEntityData('functions', functionId).catch(() => [] as ComponentTopic[]),
fetchEntityOperations('functions', functionId).catch(() => [] as Operation[]),
fetchConfigurations(functionId, 'functions'),
listEntityFaults('functions', functionId).catch(() => ({ items: [] as Fault[], count: 0 })),
]);

Expand All @@ -124,7 +105,7 @@ export function FunctionsPanel({ functionId, functionName, description, path, on
};

loadFunctionData();
}, [getFunctionHosts, fetchEntityData, fetchEntityOperations, fetchConfigurations, listEntityFaults, functionId]);
}, [getFunctionHosts, fetchEntityData, fetchEntityOperations, listEntityFaults, functionId]);

const handleResourceClick = (resourcePath: string) => {
if (onNavigate) {
Expand Down Expand Up @@ -166,7 +147,6 @@ export function FunctionsPanel({ functionId, functionName, description, path, on
if (tab.id === 'hosts') count = hosts.length;
if (tab.id === 'data') count = topics.length;
if (tab.id === 'operations') count = operations.length;
if (tab.id === 'configurations') count = storeConfigurations.get(functionId)?.length || 0;
if (tab.id === 'faults') count = faults.length;

return (
Expand Down Expand Up @@ -214,7 +194,7 @@ export function FunctionsPanel({ functionId, functionName, description, path, on
)}

{/* Resource Summary */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<button
onClick={() => setActiveTab('hosts')}
className="p-3 rounded-lg border hover:bg-accent/50 transition-colors text-left"
Expand All @@ -239,16 +219,6 @@ export function FunctionsPanel({ functionId, functionName, description, path, on
<div className="text-2xl font-semibold">{operations.length}</div>
<div className="text-xs text-muted-foreground">Operations</div>
</button>
<button
onClick={() => setActiveTab('configurations')}
className="p-3 rounded-lg border hover:bg-accent/50 transition-colors text-left"
>
<Settings className="w-4 h-4 text-violet-500 mb-1" />
<div className="text-2xl font-semibold">
{storeConfigurations.get(functionId)?.length || 0}
</div>
<div className="text-xs text-muted-foreground">Configs</div>
</button>
<button
onClick={() => setActiveTab('faults')}
className={`p-3 rounded-lg border hover:bg-accent/50 transition-colors text-left ${faults.length > 0 ? 'border-red-300 bg-red-50 dark:bg-red-950/30' : ''}`}
Expand Down
70 changes: 70 additions & 0 deletions src/lib/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,76 @@ describe('transformDataResponse', () => {
const result = transformDataResponse({ items: [] });
expect(result).toEqual([]);
});

describe('generic vendor middleware extensions', () => {
it('marks status="data" and passes value through when gateway inlines value', () => {
const raw = {
id: 'sensor/temperature',
name: 'sensor/temperature',
value: 42.5,
'x-medkit': { middleware: 'generic', access: 'read', type: 'float32' },
};
const result = transformDataResponse({ items: [raw] });
expect(result[0]?.status).toBe('data');
expect(result[0]?.data).toBe(42.5);
});

it('keeps status="metadata_only" when value is absent', () => {
const raw = { id: 'x', name: 'x', 'x-medkit': { middleware: 'generic' } };
const result = transformDataResponse({ items: [raw] });
expect(result[0]?.status).toBe('metadata_only');
expect(result[0]?.data).toBeNull();
});

it('preserves null value with status="data"', () => {
const raw = { id: 'x', name: 'x', value: null };
const result = transformDataResponse({ items: [raw] });
expect(result[0]?.status).toBe('data');
expect(result[0]?.data).toBeNull();
});

it('uses x-medkit.type as type label when ros2.type is absent', () => {
const raw = {
id: 'payload',
name: 'payload',
'x-medkit': { middleware: 'generic', type: 'u16' },
};
const result = transformDataResponse({ items: [raw] });
expect(result[0]?.type).toBe('u16');
});

it('prefers ros2.type over x-medkit.type when both present', () => {
const raw = {
id: 'x',
name: 'x',
'x-medkit': { type: 'generic-label', ros2: { type: 'std_msgs/msg/Int32' } },
};
const result = transformDataResponse({ items: [raw] });
// ROS 2 type is preferred so canonical topics stay recognisable.
// Keeps precedence consistent with `direction`.
expect(result[0]?.type).toBe('std_msgs/msg/Int32');
});

it('treats direction "output" as publish', () => {
const raw = { id: 'x', name: 'x', 'x-medkit': { direction: 'output' } };
const result = transformDataResponse({ items: [raw] });
expect(result[0]?.isPublisher).toBe(true);
expect(result[0]?.isSubscriber).toBe(false);
});

it('treats direction "input" as subscribe', () => {
const raw = { id: 'x', name: 'x', 'x-medkit': { direction: 'input' } };
const result = transformDataResponse({ items: [raw] });
expect(result[0]?.isPublisher).toBe(false);
expect(result[0]?.isSubscriber).toBe(true);
});

it('reads direction from x-medkit.direction when ros2.direction is absent', () => {
const raw = { id: 'x', name: 'x', 'x-medkit': { direction: 'publish' } };
const result = transformDataResponse({ items: [raw] });
expect(result[0]?.uniqueKey).toBe('x:publish');
});
});
});

// =============================================================================
Expand Down
49 changes: 41 additions & 8 deletions src/lib/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ interface RawOperation {
* Transform the raw operations list response into `Operation[]`.
*
* Extracts `kind`, `path`, and `type` from the `x-medkit` vendor extension.
*
* NOTE: currently only reads `x-medkit.ros2.*`. Extending the generic
* middleware fallback here (parity with `transformDataResponse`) is tracked
* separately.
*/
export function transformOperationsResponse(rawData: unknown): Operation[] {
if (!rawData || typeof rawData !== 'object') return [];
Expand Down Expand Up @@ -254,12 +258,27 @@ export function transformOperationsResponse(rawData: unknown): Operation[] {

/**
* Raw data item shape from the gateway data endpoint.
*
* Fields under `x-medkit` are generic SOVD vendor extensions. Gateways may
* populate any subset depending on the underlying middleware; the UI treats
* them as optional metadata and falls back to ROS 2 semantics when they are
* absent.
*/
interface RawDataItem {
id: string;
name?: string;
category?: string;
/** Current value inlined by the gateway when available. */
value?: unknown;
'x-medkit'?: {
/** Middleware identifier (e.g. 'ros2'); consumers treat any other value as non-ROS 2. */
middleware?: string;
/** Access mode ('read' | 'write' | 'readwrite'). */
access?: string;
/** Vendor-provided type label, used when no ROS 2 message type is available. */
type?: string;
/** Direction: 'publish'/'subscribe'/'both' or 'input'/'output' as alternative terms. */
direction?: string;
ros2?: { topic?: string; type?: string; direction?: string };
type_info?: { schema?: unknown; default_value?: unknown };
};
Expand All @@ -269,30 +288,40 @@ interface RawDataItem {
* Transform the raw data list response into `ComponentTopic[]`.
*
* Extracts topic metadata (type, direction, schema) from the `x-medkit` extension.
* When the gateway inlines a `value`, the resulting topic is marked as `status:
* 'data'` so that non-streaming middlewares render their current value immediately.
*/
export function transformDataResponse(rawData: unknown): ComponentTopic[] {
if (!rawData || typeof rawData !== 'object') return [];
const dataItems = unwrapItems<RawDataItem>(rawData);
return dataItems.map((item) => {
const rawTypeInfo = item['x-medkit']?.type_info;
const xm = item['x-medkit'];
const rawTypeInfo = xm?.type_info;
const convertedSchema = rawTypeInfo?.schema ? convertJsonSchemaToTopicSchema(rawTypeInfo.schema) : undefined;
const direction = item['x-medkit']?.ros2?.direction;
const topicName = item.name || item['x-medkit']?.ros2?.topic || item.id;
// `input`/`output` are alternative direction terms used by non-ROS 2 middlewares.
const direction = xm?.ros2?.direction ?? xm?.direction;
const topicName = item.name || xm?.ros2?.topic || item.id;
// Prefer the ROS 2 message type when present so canonical topics stay
// recognisable; the generic vendor label only fills the gap when no
// ROS 2 type was published. This keeps precedence consistent with
// `direction` above.
const typeLabel = xm?.ros2?.type ?? xm?.type;
const hasValue = item.value !== undefined;
return {
topic: topicName,
timestamp: Date.now(),
data: null,
status: 'metadata_only' as const,
type: item['x-medkit']?.ros2?.type,
data: hasValue ? item.value : null,
status: hasValue ? ('data' as const) : ('metadata_only' as const),
type: typeLabel,
type_info: convertedSchema
? {
schema: convertedSchema,
default_value: rawTypeInfo?.default_value as Record<string, unknown>,
}
: undefined,
// Direction-based fields for apps/functions.
isPublisher: direction === 'publish' || direction === 'both',
isSubscriber: direction === 'subscribe' || direction === 'both',
isPublisher: direction === 'publish' || direction === 'both' || direction === 'output',
isSubscriber: direction === 'subscribe' || direction === 'both' || direction === 'input',
uniqueKey: direction ? `${topicName}:${direction}` : topicName,
};
});
Expand All @@ -318,6 +347,10 @@ interface RawConfigurationsResponse {
*
* All meaningful data lives in the `x-medkit` extension. The `entityId` parameter
* is used as a fallback when `x-medkit` fields are absent.
*
* NOTE: currently only reads `x-medkit.ros2.*`. Extending the generic
* middleware fallback here (parity with `transformDataResponse`) is tracked
* separately.
*/
export function transformConfigurationsResponse(rawData: unknown, entityId: string): ComponentConfigurations {
if (!rawData || typeof rawData !== 'object') {
Expand Down
Loading