diff --git a/README.md b/README.md index e5e894c..8667b10 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ Simple, open-source web UI for browsing SOVD (Service-Oriented Vehicle Diagnosti ros2_medkit_web_ui is a lightweight single-page application that connects to a SOVD server and visualizes the entity hierarchy. It provides: - **Server Connection Dialog** - Enter the URL of your SOVD server (supports both `http://ip:port` and `ip:port` formats) -- **Entity Tree Sidebar** - Browse the hierarchical structure of SOVD entities with lazy-loading +- **Entity Tree Sidebar** - Browse the hierarchical structure of SOVD entities with lazy-loading, with a readiness lamp on app and component nodes (green = ready, amber = not ready) - **Entity Detail Panel** - View raw JSON details of any selected entity +- **Entity Lifecycle Status Control** - View readiness and request lifecycle transitions (start, restart, force-restart, shutdown, force-shutdown) for apps and components, degrading gracefully when no lifecycle provider is configured. Actions are gated by the current status (unavailable transitions are disabled with an explanatory tooltip), and every destructive transition (all but Start) asks for confirmation before dispatch This tool is designed for developers and integrators working with SOVD-compatible systems who need a quick way to explore and debug the entity structure. diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx index d2830af..d840fa4 100644 --- a/src/components/AppsPanel.tsx +++ b/src/components/AppsPanel.tsx @@ -11,6 +11,7 @@ import { isResourceTabId, type ResourceTabId, } from '@/components/ResourceTabs'; +import { EntityStatusControl } from '@/components/EntityStatusControl'; import type { ComponentTopic, Operation, Fault } from '@/lib/types'; type AppTab = 'overview' | ResourceTabId; @@ -136,6 +137,11 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI + + {/* Lifecycle status control (gateway 0.6.0 lifecycle API) */} +
+ +
{/* Tab Navigation */} diff --git a/src/components/EntityDetailPanel.test.tsx b/src/components/EntityDetailPanel.test.tsx index 4ed4dc4..84e3a3c 100644 --- a/src/components/EntityDetailPanel.test.tsx +++ b/src/components/EntityDetailPanel.test.tsx @@ -49,6 +49,9 @@ vi.mock('@/lib/store', () => ({ // The breadcrumb builder resolves segment types via findNode; these tests // don't load a tree, so it always falls back to position-based inference. findNode: () => null, + // EntityStatusControl (rendered for component/subcomponent entities) reads + // the status cache keyed by entityStatusKey. + entityStatusKey: (entityType: string, entityId: string) => `${entityType}:${entityId}`, })); function setStore(overrides: Record) { @@ -63,6 +66,11 @@ function setStore(overrides: Record) { refreshSelectedEntity: mockRefreshSelectedEntity, prefetchResourceCounts: mockPrefetchResourceCounts, fetchEntityData: mockFetchEntityData, + // EntityStatusControl reads these from the store; provide inert values + // so the rendered control mounts without touching the network. + client: null, + statusByEntity: {}, + fetchEntityStatus: vi.fn(), ...overrides, }; } diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index ca03e14..8c354fd 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -31,6 +31,7 @@ import { FunctionsPanel } from '@/components/FunctionsPanel'; import { ServerInfoPanel } from '@/components/ServerInfoPanel'; import { FaultsDashboard } from '@/components/FaultsDashboard'; import { UpdatesDashboard } from '@/components/UpdatesDashboard'; +import { EntityStatusControl } from '@/components/EntityStatusControl'; import { useAppStore, findNode, type AppState } from '@/lib/store'; import type { ComponentTopic, Parameter, SovdResourceEntityType } from '@/lib/types'; @@ -777,7 +778,9 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit {getEntityTypeIcon()}
- {selectedEntity.name} + + {selectedEntity.description || selectedEntity.name} + {selectedEntity.type} @@ -803,6 +806,12 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit
+ {/* Lifecycle status control (gateway 0.6.0 lifecycle API) */} + {isComponent && ( +
+ +
+ )} {/* Tab Navigation for Components */} diff --git a/src/components/EntityStatusControl.test.tsx b/src/components/EntityStatusControl.test.tsx new file mode 100644 index 0000000..61cd9d1 --- /dev/null +++ b/src/components/EntityStatusControl.test.tsx @@ -0,0 +1,264 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TooltipProvider } from '@/components/ui/tooltip'; + +// --------------------------------------------------------------------------- +// Mocks +// +// Status is read from the real store slice (statusByEntity), seeded per-test. +// Only setStatus (the transition dispatch) is mocked; the rest of api-dispatch +// stays real so the store module loads. fetchEntityStatus is seeded as a no-op +// vi.fn() in every test so the on-mount fetch does not overwrite the seeded +// status with 'unknown' against the fake client. +// --------------------------------------------------------------------------- + +const mockSetStatus = vi.fn(); + +vi.mock('@/lib/api-dispatch', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + setStatus: (...args: unknown[]) => mockSetStatus(...args), + }; +}); + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() }, +})); + +import { toast } from 'react-toastify'; +import { useAppStore } from '@/lib/store'; +import { EntityStatusControl } from './EntityStatusControl'; + +const fakeClient = { __fake: true } as never; + +/** Build an openapi-fetch style result. */ +function ok(status: number, data: unknown = undefined) { + return { data, error: undefined, response: { status } as Response }; +} + +function errResult(status: number, message: string) { + return { data: undefined, error: { message }, response: { status } as Response }; +} + +/** + * Seed the store with a cached status and a no-op fetchEntityStatus, plus the + * fake client used by setStatus dispatch. + */ +function seedStatus(key: string, value: string) { + useAppStore.setState({ + statusByEntity: { [key]: value as never }, + fetchEntityStatus: vi.fn(), + client: fakeClient, + }); +} + +const renderControl = (ui: React.ReactElement) => render({ui}); + +describe('EntityStatusControl', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSetStatus.mockResolvedValue(ok(204)); + useAppStore.setState({ + statusByEntity: {}, + fetchEntityStatus: vi.fn(), + client: fakeClient, + actuationSupported: null, + }); + }); + + afterEach(() => { + cleanup(); + }); + + // ----------------------------------------------------------------------- + // Migrated baseline coverage (now driven by the store slice) + // ----------------------------------------------------------------------- + + it('renders the current status badge from the cached status', async () => { + seedStatus('apps:motor', 'ready'); + renderControl(); + expect(await screen.findByText(/^ready$/i)).toBeInTheDocument(); + }); + + it('renders an action button for each lifecycle action', () => { + seedStatus('apps:motor', 'ready'); + renderControl(); + expect(screen.getByRole('button', { name: /^start$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^restart$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /force restart/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^shutdown$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /force shutdown/i })).toBeInTheDocument(); + }); + + it('calls setStatus with client, entityType, entityId and action on confirmed restart', async () => { + const user = userEvent.setup(); + // ready leaves Restart enabled. + seedStatus('components:host-1', 'ready'); + renderControl(); + + await user.click(screen.getByRole('button', { name: /^restart$/i })); + await user.click(await screen.findByRole('button', { name: /confirm/i })); + + await waitFor(() => expect(mockSetStatus).toHaveBeenCalledTimes(1)); + const call = mockSetStatus.mock.calls[0]!; + expect(call[0]).toBe(fakeClient); + expect(call[1]).toBe('components'); + expect(call[2]).toBe('host-1'); + expect(call[3]).toBe('restart'); + }); + + it('refreshes the status after a successful confirmed action', async () => { + const user = userEvent.setup(); + const refresh = vi.fn(); + useAppStore.setState({ + statusByEntity: { 'apps:motor': 'ready' }, + fetchEntityStatus: refresh, + client: fakeClient, + }); + renderControl(); + + // Mount effect calls fetchEntityStatus once. + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole('button', { name: /^shutdown$/i })); + await user.click(await screen.findByRole('button', { name: /confirm/i })); + + // The post-dispatch refresh calls fetchEntityStatus again. + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2)); + }); + + it('shows a disabled "not available" state when status is unavailable (501)', async () => { + // The gateway 501 maps to the cached value 'unavailable' in the store. + seedStatus('apps:motor', 'unavailable'); + renderControl(); + + expect(await screen.findByText(/not available/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^start$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^restart$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^shutdown$/i })).toBeDisabled(); + }); + + it('shows the "not available" state when the cached status is unavailable for components', async () => { + seedStatus('components:host-1', 'unavailable'); + renderControl(); + expect(await screen.findByText(/not available/i)).toBeInTheDocument(); + }); + + it('surfaces a non-501 error from setStatus inline and keeps the action enabled', async () => { + const user = userEvent.setup(); + mockSetStatus.mockResolvedValue(errResult(400, 'invalid transition')); + // start is enabled when notReady and dispatches immediately. + seedStatus('apps:motor', 'notReady'); + renderControl(); + + await user.click(screen.getByRole('button', { name: /^start$/i })); + + expect(await screen.findByText(/invalid transition/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^start$/i })).not.toBeDisabled(); + }); + + // ----------------------------------------------------------------------- + // Task 2: gating by status (disable + tooltip) + // ----------------------------------------------------------------------- + + it('disables Start with a tooltip when status is ready', async () => { + seedStatus('components:host1', 'ready'); + renderControl(); + expect(await screen.findByRole('button', { name: /^start$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^restart$/i })).toBeEnabled(); + }); + + it('disables Restart/Shutdown when status is notReady, keeps Start enabled', async () => { + seedStatus('apps:planner', 'notReady'); + renderControl(); + expect(await screen.findByRole('button', { name: /^start/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /^restart/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^shutdown/i })).toBeDisabled(); + }); + + // ----------------------------------------------------------------------- + // Task 3: confirmation dialog for non-Start actions + // ----------------------------------------------------------------------- + + it('Restart opens a confirm dialog and does not call setStatus until confirmed', async () => { + const user = userEvent.setup(); + seedStatus('apps:planner', 'ready'); + renderControl(); + + await user.click(screen.getByRole('button', { name: /^restart$/i })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(mockSetStatus).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: /confirm/i })); + await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith(fakeClient, 'apps', 'planner', 'restart')); + }); + + it('Start dispatches immediately with no dialog', async () => { + const user = userEvent.setup(); + seedStatus('apps:planner', 'notReady'); + renderControl(); + + await user.click(screen.getByRole('button', { name: /^start$/i })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith(fakeClient, 'apps', 'planner', 'start')); + }); + + // ----------------------------------------------------------------------- + // Task B: response-driven transition feedback (replaces the 501 no-op) + // ----------------------------------------------------------------------- + + it('501 transition warns "not implemented" and sets actuationSupported false', async () => { + seedStatus('apps:planner', 'notReady'); + useAppStore.setState({ actuationSupported: null }); + mockSetStatus.mockResolvedValue(errResult(501, 'no actuation provider')); + renderControl(); + + await userEvent.click(screen.getByRole('button', { name: /^start/i })); + + await waitFor(() => expect(toast.warning).toHaveBeenCalled()); + expect(toast.error).not.toHaveBeenCalled(); + expect(useAppStore.getState().actuationSupported).toBe(false); + }); + + it('2xx transition reports success and sets actuationSupported true', async () => { + seedStatus('apps:planner', 'notReady'); + useAppStore.setState({ actuationSupported: null }); + mockSetStatus.mockResolvedValue(ok(202)); + renderControl(); + + await userEvent.click(screen.getByRole('button', { name: /^start/i })); + + await waitFor(() => expect(toast.success).toHaveBeenCalled()); + expect(useAppStore.getState().actuationSupported).toBe(true); + }); + + // ----------------------------------------------------------------------- + // Task C: disable + "not implemented" note when actuationSupported === false + // ----------------------------------------------------------------------- + + it('disables all transition buttons and shows a note when actuation is unsupported', async () => { + seedStatus('apps:planner', 'notReady'); + useAppStore.setState({ actuationSupported: false }); + renderControl(); + + expect(await screen.findByRole('button', { name: /^start/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^restart/i })).toBeDisabled(); + expect(screen.getByText(/not implemented by this gateway/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/EntityStatusControl.tsx b/src/components/EntityStatusControl.tsx new file mode 100644 index 0000000..3dcc7b7 --- /dev/null +++ b/src/components/EntityStatusControl.tsx @@ -0,0 +1,288 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useEffect, useState, useCallback } from 'react'; +import { Activity, AlertCircle, Loader2, Play, Power, RotateCw, Zap } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useAppStore, entityStatusKey } from '@/lib/store'; +import { setStatus, type LifecycleEntityType } from '@/lib/api-dispatch'; +import type { LifecycleAction } from '@/lib/types'; + +interface EntityStatusControlProps { + entityType: LifecycleEntityType; + entityId: string; +} + +interface ActionConfig { + action: LifecycleAction; + label: string; + icon: typeof Play; + /** Destructive transitions use the destructive button variant. */ + variant: 'outline' | 'destructive'; +} + +const ACTIONS: ActionConfig[] = [ + { action: 'start', label: 'Start', icon: Play, variant: 'outline' }, + { action: 'restart', label: 'Restart', icon: RotateCw, variant: 'outline' }, + { action: 'force-restart', label: 'Force restart', icon: Zap, variant: 'outline' }, + { action: 'shutdown', label: 'Shutdown', icon: Power, variant: 'destructive' }, + { action: 'force-shutdown', label: 'Force shutdown', icon: Power, variant: 'destructive' }, +]; + +/** Transitions disabled for a given cached readiness value. */ +const DISABLED_BY_STATUS: Record> = { + ready: new Set(['start']), + notReady: new Set(['restart', 'shutdown', 'force-shutdown']), +}; + +/** Destructive transitions get the destructive confirm-button variant. */ +const DESTRUCTIVE_ACTIONS = new Set(['shutdown', 'force-shutdown']); + +/** + * Entity lifecycle status control for apps and components (gateway 0.6.0 + * lifecycle API). Shows the current readiness as a badge and exposes the five + * lifecycle transitions as buttons. + * + * Status is read from the shared `statusByEntity` store slice (the single + * source of truth, also feeding the tree readiness lamp). Actions are gated by + * that status (disabled + tooltip), and every transition except Start asks for + * confirmation before dispatch. + * + * The gateway returns 501 until a lifecycle provider is configured. That case + * surfaces as the cached value `'unavailable'` -> a disabled "not available" + * state rather than an error toast, so the control degrades gracefully on stock + * gateways. + */ +export function EntityStatusControl({ entityType, entityId }: EntityStatusControlProps) { + const client = useAppStore((s) => s.client); + const status = useAppStore((s) => s.statusByEntity[entityStatusKey(entityType, entityId)]); + const fetchEntityStatus = useAppStore((s) => s.fetchEntityStatus); + const setActuationSupported = useAppStore((s) => s.setActuationSupported); + const actuationSupported = useAppStore((s) => s.actuationSupported); + + const [pendingAction, setPendingAction] = useState(null); + const [confirmAction, setConfirmAction] = useState(null); + const [error, setError] = useState(null); + + // Fetch the live status on mount; the slice de-dupes against the tree lamp. + // Clear any prior error so a failed transition on one entity can't linger in + // the badge area after the selection switches to another entity. + useEffect(() => { + setError(null); + fetchEntityStatus(entityType, entityId); + }, [entityType, entityId, fetchEntityStatus]); + + const notAvailable = status === 'unavailable'; + // A 501 from any transition means the gateway has no actuation provider: + // disable every action (Start included), gateway-wide. + const actuationUnsupported = actuationSupported === false; + + const isDisabled = (action: LifecycleAction): boolean => + !client || + notAvailable || + actuationUnsupported || + pendingAction !== null || + (DISABLED_BY_STATUS[status ?? '']?.has(action) ?? false); + + const tooltipFor = (action: LifecycleAction): string => { + if (actuationUnsupported) return 'Not implemented by this gateway'; + if (status === 'ready' && action === 'start') return 'Already running'; + if (status === 'notReady' && DISABLED_BY_STATUS.notReady!.has(action)) return 'Entity is not running'; + return ''; + }; + + const dispatchAction = useCallback( + async (action: LifecycleAction) => { + if (!client) return; + setPendingAction(action); + setError(null); + try { + const result = await setStatus(client, entityType, entityId, action); + const httpStatus = result.response?.status; + if (httpStatus === 501) { + // The gateway has no actuation provider: record it gateway-wide + // so every transition button disables, and warn (not error) - + // this is a missing capability, not a failed request. + setActuationSupported(false); + const msg = result.error?.message; + toast.warning(`${action} is not implemented by this gateway${msg ? `: ${msg}` : ''}`); + return; + } + if (result.error) { + const message = result.error.message || `Failed to ${action}`; + setError(message); + toast.error(`Failed to ${action} ${entityId}: ${message}`); + return; + } + // Any 2xx proves the gateway can actuate; clear a stale "unsupported". + setActuationSupported(true); + toast.success(`${action} requested for ${entityId}`); + await fetchEntityStatus(entityType, entityId); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + toast.error(`Failed to ${action} ${entityId}: ${message}`); + } finally { + setPendingAction(null); + } + }, + [client, entityType, entityId, fetchEntityStatus, setActuationSupported] + ); + + const handleClick = useCallback( + (action: LifecycleAction) => { + // Start is non-destructive: dispatch immediately. Everything else + // interrupts a running entity, so confirm first. + if (action === 'start') { + void dispatchAction(action); + } else { + setConfirmAction(action); + } + }, + [dispatchAction] + ); + + const handleConfirm = useCallback(() => { + if (confirmAction) { + void dispatchAction(confirmAction); + } + setConfirmAction(null); + }, [confirmAction, dispatchAction]); + + const confirmLabel = confirmAction ? (ACTIONS.find((a) => a.action === confirmAction)?.label ?? confirmAction) : ''; + + const statusBadge = (() => { + if (status === 'ready') { + return ( + + ready + + ); + } + if (status === 'notReady') { + return ( + + notReady + + ); + } + return unknown; + })(); + + return ( +
+
+ + + Lifecycle + + {statusBadge} + {notAvailable && ( + + + not available + + )} +
+ +
+ {ACTIONS.map(({ action, label, icon: Icon, variant }) => { + const isPending = pendingAction === action; + const disabled = isDisabled(action); + const tip = disabled ? tooltipFor(action) : ''; + const button = ( + + ); + + // A disabled button does not fire pointer events, so wrap it + // in a focusable span to let the tooltip explain why. + if (tip) { + return ( + + + {button} + + {tip} + + ); + } + return button; + })} +
+ + {actuationUnsupported && ( + + + Transitions not implemented by this gateway (yet) + + )} + + {error && !notAvailable && ( +

+ {error} +

+ )} + + !open && setConfirmAction(null)}> + + + Confirm {confirmLabel}? + + This will {confirmLabel.toLowerCase()} {entityId}. The transition interrupts the entity and + may trigger faults. + + + + + + + + +
+ ); +} diff --git a/src/components/EntityTreeNode.test.tsx b/src/components/EntityTreeNode.test.tsx new file mode 100644 index 0000000..ba63638 --- /dev/null +++ b/src/components/EntityTreeNode.test.tsx @@ -0,0 +1,109 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warn: vi.fn(), warning: vi.fn() }, +})); + +import { useAppStore } from '@/lib/store'; +import { EntityTreeNode } from './EntityTreeNode'; + +describe('EntityTreeNode lifecycle lamp', () => { + beforeEach(() => { + useAppStore.setState({ + statusByEntity: {}, + expandedPaths: [], + loadingPaths: [], + selectedPath: null, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it('app node fetches status on mount and renders a lamp from the cache', () => { + const fetchEntityStatus = vi.fn(); + useAppStore.setState({ statusByEntity: { 'apps:talker': 'ready' }, fetchEntityStatus } as never); + render( + + ); + expect(fetchEntityStatus).toHaveBeenCalledWith('apps', 'talker'); + expect(screen.getByLabelText(/status: ready/i)).toBeInTheDocument(); + }); + + it('component node fetches status on mount mapped to the plural resource type', () => { + const fetchEntityStatus = vi.fn(); + useAppStore.setState({ statusByEntity: { 'components:host1': 'notReady' }, fetchEntityStatus } as never); + render( + + ); + expect(fetchEntityStatus).toHaveBeenCalledWith('components', 'host1'); + expect(screen.getByLabelText(/status: notReady/i)).toBeInTheDocument(); + }); + + it('area node renders no lamp and triggers no status fetch', () => { + const fetchEntityStatus = vi.fn(); + useAppStore.setState({ fetchEntityStatus } as never); + render(); + expect(fetchEntityStatus).not.toHaveBeenCalled(); + expect(screen.queryByLabelText(/status:/i)).not.toBeInTheDocument(); + }); +}); + +describe('EntityTreeNode label', () => { + beforeEach(() => { + useAppStore.setState({ statusByEntity: {}, expandedPaths: [], loadingPaths: [], selectedPath: null }); + }); + afterEach(() => cleanup()); + + it('shows the entity description as the label when present', () => { + useAppStore.setState({ fetchEntityStatus: vi.fn() } as never); + render( + + ); + expect(screen.getByText('Ubuntu 24.04.4 LTS on x86_64')).toBeInTheDocument(); + }); + + it('falls back to the name when there is no description', () => { + useAppStore.setState({ fetchEntityStatus: vi.fn() } as never); + render( + + ); + expect(screen.getByText('talker')).toBeInTheDocument(); + }); +}); diff --git a/src/components/EntityTreeNode.tsx b/src/components/EntityTreeNode.tsx index 4bd8c30..91b43ee 100644 --- a/src/components/EntityTreeNode.tsx +++ b/src/components/EntityTreeNode.tsx @@ -22,7 +22,7 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { useAppStore } from '@/lib/store'; +import { useAppStore, entityStatusKey } from '@/lib/store'; import type { EntityTreeNode as EntityTreeNodeType, TopicNodeData, Parameter } from '@/lib/types'; interface EntityTreeNodeProps { @@ -114,6 +114,21 @@ function getEntityColor(type: string, isSelected?: boolean): string { } } +/** + * Tailwind colour for the readiness lamp. Green = ready, amber = notReady, + * grey for unavailable / unknown / not-yet-fetched. + */ +function getLampColor(status: string | undefined): string { + switch (status) { + case 'ready': + return 'bg-emerald-500'; + case 'notReady': + return 'bg-amber-500'; + default: + return 'bg-muted-foreground/40'; + } +} + /** * Check if node data is TopicNodeData (from topicsInfo) */ @@ -140,6 +155,24 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { })) ); + // Lifecycle readiness lamp is only meaningful for apps and components. + // The tree uses singular node types; the lifecycle API uses plural. + const isLifecycleEntity = node.type === 'app' || node.type === 'component'; + const lifecycleType = node.type === 'app' ? 'apps' : 'components'; + const status = useAppStore((s) => + isLifecycleEntity ? s.statusByEntity[entityStatusKey(lifecycleType, node.id)] : undefined + ); + const fetchEntityStatus = useAppStore((s) => s.fetchEntityStatus); + + // Lazily fetch readiness on mount. This node only mounts when its parent is + // expanded, so this is the on-expand fetch. The slice de-dupes with the + // control's own fetch. + useEffect(() => { + if (isLifecycleEntity) { + fetchEntityStatus(lifecycleType, node.id); + } + }, [isLifecycleEntity, lifecycleType, node.id, fetchEntityStatus]); + const isExpanded = expandedPaths.includes(node.path); const isLoading = loadingPaths.includes(node.path); const isSelected = selectedPath === node.path; @@ -221,8 +254,16 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) { - - {typeof node.name === 'string' ? node.name : String(node.name || node.id || '')} + {isLifecycleEntity && ( + + )} + + + {(typeof node.description === 'string' && node.description) || + (typeof node.name === 'string' ? node.name : String(node.name || node.id || ''))} {/* Topic direction indicators */} diff --git a/src/lib/api-dispatch.ts b/src/lib/api-dispatch.ts index 1565132..33d2a2c 100644 --- a/src/lib/api-dispatch.ts +++ b/src/lib/api-dispatch.ts @@ -22,7 +22,7 @@ */ import type { MedkitClient } from '@selfpatch/ros2-medkit-client-ts'; -import type { SovdResourceEntityType } from './types'; +import type { SovdResourceEntityType, LifecycleAction } from './types'; import type { LogsQueryParams, LogsConfiguration } from './log-types'; // ============================================================================= @@ -623,3 +623,70 @@ export function putEntityLogsConfiguration( }); } } + +// ============================================================================= +// Lifecycle Status +// +// The gateway 0.6.0 lifecycle API exists ONLY for apps and components. There is +// no areas/functions equivalent, so these helpers narrow the entity type to +// 'apps' | 'components'. Each transition is a distinct PUT path (the action is +// part of the URL, not a path parameter), so setStatus maps the action string +// to the matching typed path. +// ============================================================================= + +/** Entity types that expose the lifecycle status collection. */ +export type LifecycleEntityType = Extract; + +export function getStatus( + client: MedkitClient, + entityType: LifecycleEntityType, + entityId: string, + signal?: AbortSignal +) { + switch (entityType) { + case 'apps': + return client.GET('/apps/{app_id}/status', { params: { path: { app_id: entityId } }, signal }); + case 'components': + return client.GET('/components/{component_id}/status', { + params: { path: { component_id: entityId } }, + signal, + }); + } +} + +export function setStatus( + client: MedkitClient, + entityType: LifecycleEntityType, + entityId: string, + action: LifecycleAction, + signal?: AbortSignal +) { + if (entityType === 'apps') { + const params = { path: { app_id: entityId } }; + switch (action) { + case 'start': + return client.PUT('/apps/{app_id}/status/start', { params, signal }); + case 'restart': + return client.PUT('/apps/{app_id}/status/restart', { params, signal }); + case 'force-restart': + return client.PUT('/apps/{app_id}/status/force-restart', { params, signal }); + case 'shutdown': + return client.PUT('/apps/{app_id}/status/shutdown', { params, signal }); + case 'force-shutdown': + return client.PUT('/apps/{app_id}/status/force-shutdown', { params, signal }); + } + } + const params = { path: { component_id: entityId } }; + switch (action) { + case 'start': + return client.PUT('/components/{component_id}/status/start', { params, signal }); + case 'restart': + return client.PUT('/components/{component_id}/status/restart', { params, signal }); + case 'force-restart': + return client.PUT('/components/{component_id}/status/force-restart', { params, signal }); + case 'shutdown': + return client.PUT('/components/{component_id}/status/shutdown', { params, signal }); + case 'force-shutdown': + return client.PUT('/components/{component_id}/status/force-shutdown', { params, signal }); + } +} diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts new file mode 100644 index 0000000..250bc1b --- /dev/null +++ b/src/lib/store.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock api-dispatch so the store's getStatus call hits our spy. A namespace +// spy (vi.spyOn) does not patch the store's named-import binding, so mock the +// module instead and drive the return value per-test. +vi.mock('./api-dispatch', () => ({ + getStatus: vi.fn(), + setStatus: vi.fn(), +})); + +import { useAppStore, entityStatusKey } from './store'; +import * as api from './api-dispatch'; + +const getStatusMock = vi.mocked(api.getStatus); + +describe('entityStatusKey', () => { + it('joins type and id with a colon', () => { + expect(entityStatusKey('components', 'host1')).toBe('components:host1'); + expect(entityStatusKey('apps', 'planner')).toBe('apps:planner'); + }); +}); + +describe('fetchEntityStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + useAppStore.setState({ statusByEntity: {}, client: {} as never }); + }); + + it('maps a 200 ready response into the cache', async () => { + getStatusMock.mockResolvedValue({ data: { status: 'ready' }, response: { status: 200 } } as never); + await useAppStore.getState().fetchEntityStatus('components', 'host1'); + expect(useAppStore.getState().statusByEntity[entityStatusKey('components', 'host1')]).toBe('ready'); + }); + + it('maps a 501 response to "unavailable"', async () => { + getStatusMock.mockResolvedValue({ data: undefined, response: { status: 501 } } as never); + await useAppStore.getState().fetchEntityStatus('apps', 'planner'); + expect(useAppStore.getState().statusByEntity[entityStatusKey('apps', 'planner')]).toBe('unavailable'); + }); + + it('de-dupes concurrent in-flight calls for the same key', async () => { + getStatusMock.mockResolvedValue({ data: { status: 'notReady' }, response: { status: 200 } } as never); + await Promise.all([ + useAppStore.getState().fetchEntityStatus('apps', 'planner'), + useAppStore.getState().fetchEntityStatus('apps', 'planner'), + ]); + expect(getStatusMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('actuationSupported', () => { + it('defaults to null and setActuationSupported updates it', () => { + useAppStore.setState({ actuationSupported: null }); + useAppStore.getState().setActuationSupported(false); + expect(useAppStore.getState().actuationSupported).toBe(false); + }); + it('disconnect resets the flag to null', () => { + useAppStore.setState({ actuationSupported: false }); + useAppStore.getState().disconnect(); + expect(useAppStore.getState().actuationSupported).toBeNull(); + }); +}); diff --git a/src/lib/store.ts b/src/lib/store.ts index 10e08dc..039d15e 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -16,6 +16,7 @@ import type { App, VersionInfo, SovdFunction, + EntityStatusValue, } from './types'; import { createMedkitClient, normalizeBaseUrl, type MedkitClient } from '@selfpatch/ros2-medkit-client-ts'; import type { SovdResourceEntityType } from './types'; @@ -47,6 +48,8 @@ import { getEntityLogs, getEntityLogsConfiguration, putEntityLogsConfiguration, + getStatus, + type LifecycleEntityType, } from './api-dispatch'; import type { LogCollection, LogsConfiguration, LogsFetchResult, LogsQueryParams } from './log-types'; @@ -108,6 +111,15 @@ export interface AppState { isLoadingFaults: boolean; faultStreamCleanup: (() => void) | null; + // Lifecycle status cache (apps/components only). + // Key is `${entityType}:${entityId}` (plural type); see entityStatusKey. + statusByEntity: Record; + + // Gateway-wide lifecycle actuation support, derived from observed transition + // responses: null = unknown, true = a transition succeeded (2xx), false = the + // gateway answered 501 (no actuation provider). Reset on every (re)connect. + actuationSupported: boolean | null; + // Actions connect: (url: string) => Promise; disconnect: () => void; @@ -157,6 +169,12 @@ export interface AppState { startExecutionPolling: () => void; stopExecutionPolling: () => void; + // Lifecycle status action (apps/components only) - fills statusByEntity. + fetchEntityStatus: (entityType: LifecycleEntityType, entityId: string) => Promise; + + // Records whether the gateway supports lifecycle actuation (see actuationSupported). + setActuationSupported: (value: boolean) => void; + // Faults actions fetchFaults: () => Promise; clearFault: (entityType: SovdResourceEntityType, entityId: string, faultCode: string) => Promise; @@ -734,6 +752,26 @@ export function __resetAppsRequestCache(): void { inFlightAppsRequest = null; } +/** + * Build the `statusByEntity` cache key from an entity's plural resource type + * and id (e.g. `entityStatusKey('apps', 'planner') === 'apps:planner'`). + */ +export function entityStatusKey(entityType: string, entityId: string): string { + return `${entityType}:${entityId}`; +} + +/** + * Module-level dedupe for `fetchEntityStatus`. Concurrent calls for the same + * entity (e.g. the control and the tree lamp both mounting) share one request. + * The promise is cleared on settlement so later mounts refetch fresh status. + */ +const inFlightStatusRequests = new Map>(); + +/** Reset the status-request dedupe cache. Exposed for tests. */ +export function __resetStatusRequestCache(): void { + inFlightStatusRequests.clear(); +} + export async function fetchAllAppsDeduped(client: MedkitClient): Promise[]> { if (inFlightAppsRequest) return inFlightAppsRequest; inFlightAppsRequest = client @@ -865,9 +903,16 @@ export const useAppStore = create()( isLoadingFaults: false, faultStreamCleanup: null, + // Lifecycle status cache + statusByEntity: {}, + + // Gateway-wide lifecycle actuation support (unknown until observed). + actuationSupported: null, + // Connect to ros2_medkit gateway connect: async (url: string) => { - set({ isConnecting: true, connectionError: null }); + // Clear any stale actuation flag so a reconnect re-probes support. + set({ isConnecting: true, connectionError: null, actuationSupported: null }); try { const client = createMedkitClient({ baseUrl: url, fetch: fetch.bind(globalThis) }); @@ -940,6 +985,7 @@ export const useAppStore = create()( selectedPath: null, selectedEntity: null, activeExecutions: new Map(), + actuationSupported: null, }); }, @@ -1877,6 +1923,41 @@ export const useAppStore = create()( } }, + // =========================================================================== + // LIFECYCLE STATUS ACTION (apps/components only) + // =========================================================================== + + fetchEntityStatus: async (entityType: LifecycleEntityType, entityId: string) => { + const key = entityStatusKey(entityType, entityId); + const existing = inFlightStatusRequests.get(key); + if (existing) return existing; + + const client = get().client; + if (!client) return; + + const request = (async () => { + try { + const result = await getStatus(client, entityType, entityId); + let value: EntityStatusValue = 'unknown'; + if (result.response?.status === 501) { + value = 'unavailable'; + } else if (result.data?.status === 'ready' || result.data?.status === 'notReady') { + value = result.data.status; + } + set((s) => ({ statusByEntity: { ...s.statusByEntity, [key]: value } })); + } catch { + set((s) => ({ statusByEntity: { ...s.statusByEntity, [key]: 'unknown' } })); + } finally { + inFlightStatusRequests.delete(key); + } + })(); + + inFlightStatusRequests.set(key, request); + return request; + }, + + setActuationSupported: (value: boolean) => set({ actuationSupported: value }), + // =========================================================================== // FAULTS ACTIONS (Diagnostic Trouble Codes) // =========================================================================== diff --git a/src/lib/types.ts b/src/lib/types.ts index c482b73..7d75b98 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -64,6 +64,29 @@ export type { */ export type SovdResourceEntityType = 'areas' | 'components' | 'apps' | 'functions'; +// ============================================================================= +// Lifecycle Status (gateway 0.6.0 lifecycle API) +// +// Only apps and components expose the lifecycle status collection. The gateway +// returns 501 until a lifecycle provider is configured; callers should treat +// that as "not available" rather than an error. +// ============================================================================= + +/** + * Lifecycle transition action. Each maps to a distinct + * PUT /{entity}/{id}/status/{action} endpoint. + */ +export type LifecycleAction = 'start' | 'restart' | 'force-restart' | 'shutdown' | 'force-shutdown'; + +/** + * Lifecycle readiness value reported by GET /{entity}/{id}/status and carried + * in the `status` field of the GET /apps/{id} and GET /components/{id} responses. + */ +export type LifecycleStatus = 'ready' | 'notReady'; + +/** Cached lifecycle status value for an entity (apps/components only). */ +export type EntityStatusValue = 'ready' | 'notReady' | 'unavailable' | 'unknown'; + /** * QoS profile for a topic endpoint */ @@ -103,6 +126,8 @@ export interface SovdEntity { id: string; /** Display name */ name: string; + /** Optional human-friendly description (e.g. the host OS for a component). */ + description?: string; /** Entity type (e.g., "component", "application", "signal") */ type: string; /** API path for this entity */